Dev/v1.2 #4

Merged
ismailmasseran merged 4 commits from dev/v1.2 into main 2026-07-06 12:50:55 +08:00
59 changed files with 1538 additions and 9156 deletions
Showing only changes of commit 61345e9cf0 - Show all commits
+13 -2
View File
@@ -16,5 +16,16 @@
[ ] jana surat lepas lulus anggota
## Present to Boss (2/7/2026)
[ ] discuss logo baru MyKOPKB
[ ] layout footer untuk surat rasmi
[x] discuss logo baru MyKOPKB
[x] layout footer untuk surat rasmi
[ ] maklumat pekerjaan kena buat dropdown untuk nama majikan
[ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] tukar waris ke penama
[x] ic dan surat pengesahan majikkan perlu mandatori
[ ] semakan lembaga tukar jadi maklumat keputusan
[x] boleh attach document untuk peringkat pentadbir
[x] surat attach dekat email selepas bos
[ ] pembayaran pertama tukar jadi one-off
[ ] syer maksima silap tukar jadi rm50.00
[ ] letak sign digital
[x] running no anggota dalam surat dan masa.
@@ -2,19 +2,28 @@
namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationFailedNotification extends Notification
class MembershipApplicationFailedNotification extends Notification implements ShouldQueue
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected MembershipApplication $application
public MembershipApplication $application,
public Document $resultLetter,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
@@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage)
->subject('Keputusan Permohonan Keahlian')
->markdown('membershipapplication::emails.failed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
return $this->mailMessage(
subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS',
view: 'membershipapplication::emails.failed',
data: [
'application' => $this->application,
'applicant' => $applicant,
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
[
'as' => $this->resultLetter->name,
'mime' => $this->resultLetter->mime_type,
]
);
}
}
@@ -2,19 +2,28 @@
namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationPassedNotification extends Notification
class MembershipApplicationPassedNotification extends Notification implements ShouldQueue
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected MembershipApplication $application
public MembershipApplication $application,
public Document $resultLetter,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
@@ -23,13 +32,21 @@ class MembershipApplicationPassedNotification extends Notification
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage)
->subject('Permohonan Keahlian Diluluskan')
->markdown('membershipapplication::emails.passed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
return $this->mailMessage(
subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS',
view: 'membershipapplication::emails.passed',
data: [
'application' => $this->application,
'applicant' => $applicant,
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
[
'as' => $this->resultLetter->name,
'mime' => $this->resultLetter->mime_type,
]
);
}
}
@@ -18,6 +18,8 @@ class MembershipApplication extends Model
public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter';
public const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment';
public const MINIMUM_SHARE_CAPITAL = 500;
public const SHARE_INSTALLMENT_MONTHS = 6;
@@ -17,6 +17,7 @@ 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\CompleteMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
@@ -180,18 +181,19 @@ class MembershipApplicationController extends Controller
]);
}
public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse
public function complete(CompleteMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse
{
$this->authorize('complete', $membershipApplication);
$application = $this->membershipApplicationService->complete(
$membershipApplication,
$request->user()
$request->user(),
$request->validated('board_meeting_reference'),
);
$message = $application->board_result === BoardResult::Pass->value
? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.'
: 'Permohonan selesai. E-mel keputusan dihantar.';
? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.'
: 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.';
return response()->json([
'success' => true,
@@ -206,7 +208,8 @@ class MembershipApplicationController extends Controller
$result = $this->membershipApplicationService->completeBatch(
$request->validated('application_ids'),
$request->user()
$request->user(),
$request->validated('board_meeting_reference'),
);
$succeededCount = count($result['succeeded']);
@@ -245,6 +248,22 @@ class MembershipApplicationController extends Controller
return $this->documentService->downloadDocument($document->id);
}
public function deleteDocument(MembershipApplication $membershipApplication, string $documentId): JsonResponse
{
$this->authorize('update', $membershipApplication);
$application = $this->membershipApplicationService->deleteDocument(
$membershipApplication,
$documentId,
);
return response()->json([
'success' => true,
'message' => 'Lampiran pentadbir berjaya dipadam.',
'data' => new MembershipApplicationResource($application),
]);
}
public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse {
$this->authorize('generateResultLetter', $membershipApplication);
@@ -19,6 +19,7 @@ class BatchCompleteRequest extends FormRequest
return [
'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
'board_meeting_reference' => 'required|string|max:255',
];
}
@@ -33,6 +34,8 @@ class BatchCompleteRequest extends FormRequest
'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CompleteMembershipApplicationRequest 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.',
];
}
}
@@ -48,7 +48,7 @@ class StoreMembershipApplicationRequest extends FormRequest
'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.employer_letter' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
];
}
@@ -89,6 +89,8 @@ class StoreMembershipApplicationRequest extends FormRequest
'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
'documents.employer_letter.required' => 'Surat pengesahan majikan diperlukan.',
'documents.employer_letter.mimes' => 'Surat pengesahan majikan mestilah PDF, JPG, JPEG atau PNG.',
];
}
}
@@ -4,6 +4,7 @@ namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\MembershipApplication\Entities\MembershipApplication;
class UploadMembershipApplicationDocumentRequest extends FormRequest
{
@@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest
public function rules(): array
{
return [
'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])],
'type' => ['required', Rule::in([
'ic_copy',
'photo',
'salary_slip',
'employer_letter',
MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE,
])],
'file' => [
'required',
'file',
@@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
return MembershipApplication::with([
'applicant',
'heirs',
'user:id,member_number',
'references.member:id,name,ic_number',
'documents',
'reviews.reviewer:id,name',
@@ -1,18 +1,15 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
@include('emails.partials.header')
# Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**,
Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga.
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga.
Surat keputusan rasmi dilampirkan dalam e-mel ini.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent
@@ -1,18 +1,15 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
@include('emails.partials.header')
# Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**,
Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga.
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga.
Surat keputusan rasmi dilampirkan dalam e-mel ini.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent
@@ -22,7 +22,7 @@
</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 }}.
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif.
</p>
@if ($isPassed)
@@ -67,7 +67,7 @@
<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-name">MUHAMMAD NAFIS BIN ZAINUDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection
@@ -38,6 +38,7 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
// Membership application upload document routes
Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
Route::delete('membership-applications/{membershipApplication}/documents/{documentId}', [MembershipApplicationController::class, 'deleteDocument'])->name('membership-application.delete-document');
// download document routes
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
@@ -2,6 +2,7 @@
namespace Modules\MembershipApplication\Services;
use App\Models\Document;
use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -137,15 +138,38 @@ class MembershipApplicationService
$this->documentService->validateFile($file);
$application->documentsOfType($type)->get()->each(
fn ($document) => $this->documentService->deleteDocument($document->id)
);
if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
$application->documentsOfType($type)->get()->each(
fn ($document) => $this->documentService->deleteDocument($document->id)
);
}
$this->documentService->uploadDocument($application, $file, $type);
return $this->repository->findByIdWithRelations($application->id);
}
public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication
{
if ($application->status === ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
]);
}
$document = $application->documents()->findOrFail($documentId);
if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
throw ValidationException::withMessages([
'document' => ['Hanya lampiran pentadbir boleh dipadam.'],
]);
}
$this->documentService->deleteDocument($document->id);
return $this->repository->findByIdWithRelations($application->id);
}
public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
if ($application->status !== ApplicationStatus::Submitted) {
throw ValidationException::withMessages([
@@ -230,8 +254,11 @@ class MembershipApplicationService
});
}
public function complete(MembershipApplication $application, User $processedBy): MembershipApplication
{
public function complete(
MembershipApplication $application,
User $processedBy,
string $boardMeetingReference,
): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
@@ -244,13 +271,16 @@ class MembershipApplicationService
]);
}
return DB::transaction(function () use ($application) {
return DB::transaction(function () use ($application, $boardMeetingReference) {
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]);
$application->refresh();
}
$this->sendResultNotification($application);
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference);
$this->sendResultNotification($application, $resultLetter);
$application->update([
'status' => ApplicationStatus::Completed,
@@ -268,7 +298,7 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}>
* }
*/
public function completeBatch(array $applicationIds, User $processedBy): array
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array
{
$succeeded = [];
$failed = [];
@@ -287,7 +317,7 @@ class MembershipApplicationService
}
try {
$succeeded[] = $this->complete($application, $processedBy);
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference);
} catch (ValidationException $e) {
$failed[] = [
'id' => $id,
@@ -317,40 +347,47 @@ class MembershipApplicationService
}
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,
);
$this->storeResultLetter($application, $boardMeetingReference);
return $this->repository->findByIdWithRelations($application->id);
});
}
protected function storeResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
): Document {
$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';
return $this->documentService->storeGeneratedDocument(
$application,
$pdf,
$fileName,
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
);
}
/**
* @return array<string, mixed>
*/
@@ -378,12 +415,13 @@ class MembershipApplicationService
): array {
$applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value;
$monthlyShareInstallment = number_format(
MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
2,
'.',
'',
);
// $monthlyShareInstallment = number_format(
// MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
// 2,
// '.',
// '',
// );
$monthlyShareInstallment = 84;
return [
'application' => $application,
@@ -400,6 +438,7 @@ class MembershipApplicationService
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
'memberNumber' => $isPassed ? $application->user?->member_number : null,
];
}
@@ -488,20 +527,30 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application));
}
protected function sendResultNotification(MembershipApplication $application): void
protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
{
$application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application)
: new MembershipApplicationFailedNotification($application);
? new MembershipApplicationPassedNotification($application, $resultLetter)
: new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification);
}
protected function generateMemberNumber(): int
{
return ((int) User::max('member_number')) + 1;
$latestUser = User::query()
->whereNotNull('member_number')
->orderByDesc('member_number')
->lockForUpdate()
->first();
if ($latestUser === null && DB::getDriverName() === 'pgsql') {
DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]);
}
return ((int) ($latestUser?->member_number ?? 0)) + 1;
}
/**
@@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
@@ -22,7 +22,7 @@ return new class extends Migration
});
Schema::create('job_batches', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
@@ -35,7 +35,7 @@ return new class extends Migration
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 275 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 275 KiB

-129
View File
@@ -1,129 +0,0 @@
<script lang="ts" setup>
import {
AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Preview,
SectionTitle,
SectionContent,
InstallPackage,
PreviewCode,
} from "@/components/docs";
import { Box } from "@/components/ui/box";
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<Box raised="single" class="w-full">
<AccordionRoot class="w-full" :default-value="['product-information']">
<AccordionItem value="product-information">
<AccordionTrigger>Product Information</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
Our flagship product combines cutting-edge technology with
sleek design. Built with premium materials, it offers
unparalleled performance and reliability.
</p>
<p>
Key features include advanced processing capabilities, and an
intuitive user interface designed for both beginners and
experts.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="shipping-details">
<AccordionTrigger>Shipping Details</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
We offer worldwide shipping through trusted courier partners.
Standard delivery takes 3-5 business days, while express
shipping ensures delivery within 1-2 business days.
</p>
<p>
All orders are carefully packaged and fully insured. Track
your shipment in real-time through our dedicated tracking
portal.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="return-policy">
<AccordionTrigger>Return Policy</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
We stand behind our products with a comprehensive 30-day
return policy. If you're not completely satisfied, simply
return the item in its original condition.
</p>
<p>
Our hassle-free return process includes free return shipping
and full refunds processed within 48 hours of receiving the
returned item.
</p>
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</Box>
</template>
<template #code>
<PreviewCode>
{{ `
<Box raised="single" class="w-full">
<AccordionRoot class="w-full" :default-value="['product-information']">
<AccordionItem value="product-information">
<AccordionTrigger>Product Information</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
Our flagship product combines cutting-edge technology with
sleek design. Built with premium materials, it offers
unparalleled performance and reliability.
</p>
<p>
Key features include advanced processing capabilities, and an
intuitive user interface designed for both beginners and
experts.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="shipping-details">
<AccordionTrigger>Shipping Details</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
We offer worldwide shipping through trusted courier partners.
Standard delivery takes 3-5 business days, while express
shipping ensures delivery within 1-2 business days.
</p>
<p>
All orders are carefully packaged and fully insured. Track
your shipment in real-time through our dedicated tracking
portal.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="return-policy">
<AccordionTrigger>Return Policy</AccordionTrigger>
<AccordionContent>
<p class="mb-2">
We stand behind our products with a comprehensive 30-day
return policy. If you're not completely satisfied, simply
return the item in its original condition.
</p>
<p>
Our hassle-free return process includes free return shipping
and full refunds processed within 48 hours of receiving the
returned item.
</p>
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</Box>
` }}
</PreviewCode>
</template>
</Preview>
</div>
</template>
-239
View File
@@ -1,239 +0,0 @@
<script lang="ts" setup>
import {
AvatarRoot,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import {
Preview,
SectionTitle,
SectionContent,
InstallPackage,
PreviewCode,
} from "@/components/docs";
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<AvatarRoot>
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<AvatarRoot>
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>Install the following dependencies:</SectionContent>
<InstallPackage>add @zag-js/vue @zag-js/avatar</InstallPackage>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/avatar/AvatarFallback.vue">
{{ `
<script lang="ts" setup>
import type { Api } from "@zag-js/avatar";
import { cn } from "@mykopkb/core/utils/cn";
import { avatarFallback } from "@mykopkb/core/styles/avatar.styles";
import { inject } from "vue";
import { Slot } from "@/components/ui/slot";
const {
class: className,
asChild = false,
...props
} = defineProps<{
asChild?: boolean;
class?: string;
}>();
const api = inject<Api>("avatarApi");
</script>
<template>
<Slot :class="cn(avatarFallback, className)" v-bind="{ ...props, ...$attrs, ...api?.getFallbackProps() }">
<slot v-if="asChild" />
<span v-else>
<slot />
</span>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/avatar/AvatarImage.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { avatarImage } from "@mykopkb/core/styles/avatar.styles";
import { inject } from "vue";
import type { Api } from "@zag-js/avatar";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
const api = inject<Api>("avatarApi");
</script>
<template>
<img :class="cn(avatarImage, className)" v-bind="{ ...props, ...$attrs, ...api?.getImageProps() }" />
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/avatar/AvatarRoot.vue">
{{`
<script lang="ts" setup>
import * as avatar from "@zag-js/avatar";
import { provide, computed } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import type { Props } from "@zag-js/avatar";
import { cn } from "@mykopkb/core/utils/cn";
import {
avatarRootVariants,
type AvatarRootVariants,
} from "@mykopkb/core/styles/avatar.styles";
import { Slot } from "@/components/ui/slot";
const {
class: className,
bordered,
asChild = false,
...props
} = defineProps<
AvatarRootVariants &
Partial<Props> & {
class?: string;
asChild?: boolean;
}
>();
const service = useMachine(avatar.machine, {
...props,
id: crypto.randomUUID(),
});
const api = computed(() => avatar.connect(service, normalizeProps));
provide("avatarApi", api);
</script>
<template>
<Slot :class="
cn(
avatarRootVariants({
bordered,
className,
}),
className
)
" v-bind="{ ...props, ...$attrs, ...api.getRootProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/avatar/index.ts">
{{ `
export { default as AvatarRoot } from "./AvatarRoot.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import {
AvatarRoot,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<AvatarRoot>
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
` }}
</PreviewCode>
</div>
<div id="variants">
<SectionTitle>Variants</SectionTitle>
<SectionContent>A collection of components you can use.</SectionContent>
<Preview>
<template #preview>
<AvatarRoot :bordered="false">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<AvatarRoot :bordered="false">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<AvatarRoot class="rounded-full">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<AvatarRoot class="rounded-full">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<AvatarRoot class="rounded-full" :bordered="false">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<AvatarRoot class="rounded-full" :bordered="false">
<AvatarFallback>PA</AvatarFallback>
<AvatarImage src="https://i.pravatar.cc/300" alt="avatar" />
</AvatarRoot>
` }}
</PreviewCode>
</template>
</Preview>
</div>
</template>
-351
View File
@@ -1,351 +0,0 @@
<script lang="ts" setup>
import { ChevronDown, CheckSquare } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import {
Preview,
SectionTitle,
SectionContent,
PreviewCode,
} from "@/components/docs";
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<Badge variant="primary">12%</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge variant="primary">12%</Badge>
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/badge/Badge.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import {
TooltipRoot,
TooltipTrigger,
TooltipPositioner,
TooltipContent,
} from "@/components/ui/tooltip";
import {
badgeVariants,
type BadgeVariants,
} from "@mykopkb/core/styles/badge.styles";
const {
class: className,
look,
variant,
content,
...props
} = defineProps<
BadgeVariants & {
class?: string;
content?: string;
}
>();
</script>
<template>
<TooltipRoot :disabled="!content">
<TooltipTrigger as-child>
<span :class="cn(badgeVariants({ look, variant, className }))" v-bind="{ ...props, ...$attrs }">
<slot />
</span>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>\{\{ content \}\}</TooltipContent>
</TooltipPositioner>
</TooltipRoot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/badge/index.ts">
{{ `
export { default as Badge } from "./Badge.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import { Badge } from "@/components/ui/badge";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<Badge>12%</Badge>
` }}
</PreviewCode>
</div>
<div id="variants">
<SectionTitle>Variants</SectionTitle>
<SectionContent>A collection of components you can use.</SectionContent>
<Preview>
<template #preview>
<Badge variant="primary">12%</Badge>
<Badge variant="secondary">12%</Badge>
<Badge variant="success">12%</Badge>
<Badge variant="danger">12%</Badge>
<Badge variant="pending">12%</Badge>
<Badge variant="warning">12%</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge variant="primary">12%</Badge>
<Badge variant="secondary">12%</Badge>
<Badge variant="success">12%</Badge>
<Badge variant="danger">12%</Badge>
<Badge variant="pending">12%</Badge>
<Badge variant="warning">12%</Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge variant="success"> 12%
<ChevronDown />
</Badge>
<Badge variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge variant="warning"> 12%
<ChevronDown />
</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge variant="success"> 12%
<ChevronDown />
</Badge>
<Badge variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge variant="warning"> 12%
<ChevronDown />
</Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge look="outline" variant="primary"> 12% </Badge>
<Badge look="outline" variant="secondary"> 12% </Badge>
<Badge look="outline" variant="success"> 12% </Badge>
<Badge look="outline" variant="danger"> 12% </Badge>
<Badge look="outline" variant="pending"> 12% </Badge>
<Badge look="outline" variant="warning"> 12% </Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge look="outline" variant="primary"> 12% </Badge>
<Badge look="outline" variant="secondary"> 12% </Badge>
<Badge look="outline" variant="success"> 12% </Badge>
<Badge look="outline" variant="danger"> 12% </Badge>
<Badge look="outline" variant="pending"> 12% </Badge>
<Badge look="outline" variant="warning"> 12% </Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge look="outline" variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="success"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="warning"> 12%
<ChevronDown />
</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge look="outline" variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="success"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge look="outline" variant="warning"> 12%
<ChevronDown />
</Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge look="filled" variant="primary"> 12% </Badge>
<Badge look="filled" variant="secondary"> 12% </Badge>
<Badge look="filled" variant="success"> 12% </Badge>
<Badge look="filled" variant="danger"> 12% </Badge>
<Badge look="filled" variant="pending"> 12% </Badge>
<Badge look="filled" variant="warning"> 12% </Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge look="filled" variant="primary"> 12% </Badge>
<Badge look="filled" variant="secondary"> 12% </Badge>
<Badge look="filled" variant="success"> 12% </Badge>
<Badge look="filled" variant="danger"> 12% </Badge>
<Badge look="filled" variant="pending"> 12% </Badge>
<Badge look="filled" variant="warning"> 12% </Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge look="filled" variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="success"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="warning"> 12%
<ChevronDown />
</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge look="filled" variant="primary"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="secondary"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="success"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="danger"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="pending"> 12%
<ChevronDown />
</Badge>
<Badge look="filled" variant="warning"> 12%
<ChevronDown />
</Badge>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Badge look="outline" variant="primary" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="secondary" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="success" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="danger" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="pending" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="warning" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
</template>
<template #code>
<PreviewCode>
{{ `
<Badge look="outline" variant="primary" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="secondary" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="success" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="danger" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="pending" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
<Badge look="outline" variant="warning" content="12% Higher than last month">
<CheckSquare /> 12%
</Badge>
` }}
</PreviewCode>
</template>
</Preview>
</div>
</template>
File diff suppressed because it is too large Load Diff
-445
View File
@@ -1,445 +0,0 @@
<script lang="ts" setup>
import {
CarouselRoot,
CarouselControl,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselIndicatorGroup,
CarouselIndicator,
CarouselItemGroup,
CarouselItem,
} from "@/components/ui/carousel";
import {
Preview,
SectionTitle,
SectionContent,
InstallPackage,
PreviewCode,
} from "@/components/docs";
const images = Array.from(
{ length: 5 },
(_, i) => `https://picsum.photos/seed/${i + 1}/500/300`
);
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<CarouselRoot :default-page="0" :slide-count="images.length" class="size-72">
<CarouselControl>
<CarouselPrevTrigger />
<CarouselNextTrigger />
</CarouselControl>
<CarouselIndicatorGroup>
<CarouselIndicator v-for="(_, index) in images" :key="index" :index="index" />
</CarouselIndicatorGroup>
<CarouselItemGroup>
<CarouselItem v-for="(image, index) in images" :key="index" :index="index"
class="text-5xl bold flex items-center justify-center">
{{ index + 1 }}
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
</template>
<template #code>
<PreviewCode>
{{`
const images = Array.from(
{ length: 5 },
(_, i) => \`https://picsum.photos/seed/\${i + 1}/500/300\`
);
<CarouselRoot :default-page="0" :slide-count="images.length" class="size-72">
<CarouselControl>
<CarouselPrevTrigger />
<CarouselNextTrigger />
</CarouselControl>
<CarouselIndicatorGroup>
<CarouselIndicator v-for="(_, index) in images" :key="index" :index="index" />
</CarouselIndicatorGroup>
<CarouselItemGroup>
<CarouselItem v-for="(image, index) in images" :key="index" :index="index"
class="text-5xl bold flex items-center justify-center">
\{\{ index + 1 \}\}
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>Install the following dependencies:</SectionContent>
<InstallPackage>add @zag-js/vue @zag-js/carousel</InstallPackage>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/carousel/CarouselRoot.vue">
{{`
<script lang="ts" setup>
import * as carousel from "@zag-js/carousel";
import { useMachine, normalizeProps } from "@zag-js/vue";
import type { Props } from "@zag-js/carousel";
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { carouselRoot } from "@mykopkb/core/styles/carousel.styles";
import { computed, provide } from "vue";
const {
class: className,
defaultPage,
slideCount,
spacing = "2rem",
allowMouseDrag = true,
asChild = false,
...props
} = defineProps<Partial<Props> & { asChild?: boolean; class?: string }>();
const service = useMachine(carousel.machine, {
defaultPage,
slideCount,
spacing,
allowMouseDrag,
...props,
id: crypto.randomUUID(),
});
const api = computed(() => carousel.connect(service, normalizeProps));
provide("carouselApi", api);
</script>
<template>
<Slot :class="cn(carouselRoot, className)" v-bind="{ ...props, ...$attrs, ...api.getRootProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselControl.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { carouselControl } from "@mykopkb/core/styles/carousel.styles";
import type { Api } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
class?: string;
asChild?: boolean;
}>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot :class="cn(carouselControl, className)" v-bind="{ ...props, ...$attrs, ...api?.getControlProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselPrevTrigger.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "@lucide/vue";
import { carouselPrevTrigger } from "@mykopkb/core/styles/carousel.styles";
import type { Api } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
class?: string;
asChild?: boolean;
}>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot v-bind="{ ...props, ...$attrs, ...api?.getPrevTriggerProps() }">
<slot v-if="asChild" />
<Button variant="ghost" v-else :class="cn(carouselPrevTrigger, className)">
<slot v-if="$slots.default" />
<ArrowLeft v-else />
</Button>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselNextTrigger.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { Button } from "@/components/ui/button";
import { ArrowRight } from "@lucide/vue";
import { carouselNextTrigger } from "@mykopkb/core/styles/carousel.styles";
import type { Api } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
class?: string;
asChild?: boolean;
}>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot v-bind="{ ...props, ...$attrs, ...api?.getNextTriggerProps() }">
<slot v-if="asChild" />
<Button variant="ghost" v-else :class="cn(carouselNextTrigger, className)">
<slot v-if="$slots.default" />
<ArrowRight v-else />
</Button>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselIndicatorGroup.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { carouselIndicatorGroup } from "@mykopkb/core/styles/carousel.styles";
import type { Api } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
class?: string;
asChild?: boolean;
}>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot :class="cn(carouselIndicatorGroup, className)"
v-bind="{ ...props, ...$attrs, ...api?.getIndicatorGroupProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselIndicator.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { carouselIndicator } from "@mykopkb/core/styles/carousel.styles";
import type { Api, IndicatorProps } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
index,
...props
} = defineProps<
{
class?: string;
asChild?: boolean;
} & IndicatorProps
>();
const api = inject<Api>("carouselApi");
</script>
<template>
<button :class="cn(carouselIndicator, className)"
v-bind="{ ...props, ...$attrs, ...api?.getIndicatorProps({ index }) }" />
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselItemGroup.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { carouselItemGroup } from "@mykopkb/core/styles/carousel.styles";
import type { Api } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
class?: string;
asChild?: boolean;
}>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot :class="cn(carouselItemGroup, className)" v-bind="{ ...props, ...$attrs, ...api?.getItemGroupProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/CarouselItem.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import { Box } from "@/components/ui/box";
import { carouselItem } from "@mykopkb/core/styles/carousel.styles";
import type { Api, ItemProps } from "@zag-js/carousel";
import { inject } from "vue";
const {
class: className,
asChild = false,
index,
...props
} = defineProps<
{
class?: string;
asChild?: boolean;
} & ItemProps
>();
const api = inject<Api>("carouselApi");
</script>
<template>
<Slot v-bind="{ ...props, ...$attrs, ...api?.getItemProps({ index }) }">
<slot v-if="asChild" />
<Box v-else :class="cn(carouselItem, className)">
<slot />
</Box>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/carousel/index.ts">
{{ `
export { default as CarouselRoot } from "./CarouselRoot.vue";
export { default as CarouselControl } from "./CarouselControl.vue";
export { default as CarouselPrevTrigger } from "./CarouselPrevTrigger.vue";
export { default as CarouselNextTrigger } from "./CarouselNextTrigger.vue";
export { default as CarouselIndicatorGroup } from "./CarouselIndicatorGroup.vue";
export { default as CarouselIndicator } from "./CarouselIndicator.vue";
export { default as CarouselItemGroup } from "./CarouselItemGroup.vue";
export { default as CarouselItem } from "./CarouselItem.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import {
CarouselRoot,
CarouselControl,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselIndicatorGroup,
CarouselIndicator,
CarouselItemGroup,
CarouselItem,
} from "@/components/ui/carousel";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<CarouselRoot :default-page="0" :slide-count="images.length" class="size-72">
<CarouselControl>
<CarouselPrevTrigger />
<CarouselNextTrigger />
</CarouselControl>
<CarouselIndicatorGroup>
<CarouselIndicator v-for="(_, index) in images" :key="index" :index="index" />
</CarouselIndicatorGroup>
<CarouselItemGroup>
<CarouselItem v-for="(image, index) in images" :key="index" :index="index"
class="text-5xl bold flex items-center justify-center">
\{\{ index + 1 \}\}
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
` }}
</PreviewCode>
</div>
<div id="variants">
<SectionTitle>Variants</SectionTitle>
<SectionContent>A collection of components you can use.</SectionContent>
<Preview>
<template #preview>
<CarouselRoot :default-page="0" :slide-count="images.length" class="size-72">
<CarouselControl>
<CarouselPrevTrigger />
<CarouselNextTrigger />
</CarouselControl>
<CarouselIndicatorGroup>
<CarouselIndicator v-for="(_, index) in images" :key="index" :index="index" />
</CarouselIndicatorGroup>
<CarouselItemGroup>
<CarouselItem v-for="(image, index) in images" :key="index" :index="index">
<img :src="image" :alt="`Slide $\{index\}`" />
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<CarouselRoot :default-page="0" :slide-count="images.length" class="size-72">
<CarouselControl>
<CarouselPrevTrigger />
<CarouselNextTrigger />
</CarouselControl>
<CarouselIndicatorGroup>
<CarouselIndicator v-for="(_, index) in images" :key="index" :index="index" />
</CarouselIndicatorGroup>
<CarouselItemGroup>
<CarouselItem v-for="(image, index) in images" :key="index" :index="index">
<img :src="image" :alt="\`Slide $\{index\}\`" />
</CarouselItem>
</CarouselItemGroup>
</CarouselRoot>
` }}
</PreviewCode>
</template>
</Preview>
</div>
</template>
-235
View File
@@ -1,235 +0,0 @@
<script lang="ts" setup>
import {
CheckboxRoot,
CheckboxLabel,
CheckboxControl,
} from "@/components/ui/checkbox";
import {
Preview,
SectionTitle,
SectionContent,
InstallPackage,
PreviewCode,
} from "@/components/docs";
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<CheckboxRoot>
<CheckboxControl />
<CheckboxLabel>Accept terms and conditions</CheckboxLabel>
</CheckboxRoot>
</template>
<template #code>
<PreviewCode>
{{ `
<CheckboxRoot>
<CheckboxControl />
<CheckboxLabel>Accept terms and conditions</CheckboxLabel>
</CheckboxRoot>
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>Install the following dependencies:</SectionContent>
<InstallPackage>add @zag-js/vue @zag-js/checkbox</InstallPackage>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/checkbox/CheckboxRoot.vue">
{{`
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { checkboxRoot } from "@mykopkb/core/styles/checkbox.styles";
import * as checkbox from "@zag-js/checkbox";
import { useMachine, normalizeProps } from "@zag-js/vue";
import type { Props } from "@zag-js/checkbox";
import { CheckboxHiddenInput } from ".";
import { computed, provide } from "vue";
const {
class: className,
checked = undefined,
...props
} = defineProps<Partial<Props> & { class?: string }>();
const service = useMachine(
checkbox.machine,
computed(() => ({
...props,
checked,
id: crypto.randomUUID(),
}))
);
const api = computed(() => checkbox.connect(service, normalizeProps));
provide("checkboxApi", api);
</script>
<template>
<label :class="cn(checkboxRoot, className)" v-bind="{ ...props, ...$attrs, ...api.getRootProps() }">
<slot />
<CheckboxHiddenInput />
</label>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/checkbox/CheckboxLabel.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { cn } from "@mykopkb/core/utils/cn";
import type { Api } from "@zag-js/checkbox";
import { checkboxLabel } from "@mykopkb/core/styles/checkbox.styles";
import { label } from "@mykopkb/core/styles/label.styles";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
asChild?: boolean;
class?: string;
}>();
const api = inject<Api>("checkboxApi");
</script>
<template>
<Slot :class="cn([label, checkboxLabel, className])" v-bind="{ ...props, ...$attrs, ...api?.getLabelProps() }">
<slot v-if="asChild" />
<span v-else>
<slot />
</span>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/checkbox/CheckboxControl.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import { CheckIcon } from "@lucide/vue";
import { cn } from "@mykopkb/core/utils/cn";
import { checkboxControl } from "@mykopkb/core/styles/checkbox.styles";
import { CheckboxIndicator } from ".";
import type { Api } from "@zag-js/checkbox";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
asChild?: boolean;
class?: string;
}>();
const api = inject<Api>("checkboxApi");
</script>
<template>
<Slot :class="cn(checkboxControl, className)" v-bind="{ ...props, ...$attrs, ...api?.getControlProps() }">
<slot v-if="asChild" />
<div v-else>
<CheckboxIndicator>
<CheckIcon />
</CheckboxIndicator>
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/checkbox/CheckboxIndicator.vue">
{{ `
<script lang="ts" setup>
import { Slot } from "@/components/ui/slot";
import type { Api } from "@zag-js/checkbox";
import { cn } from "@mykopkb/core/utils/cn";
import { checkboxIndicator } from "@mykopkb/core/styles/checkbox.styles";
import { inject } from "vue";
const {
class: className,
asChild = false,
...props
} = defineProps<{
asChild?: boolean;
class?: string;
}>();
const api = inject<Api>("checkboxApi");
</script>
<template>
<Slot :class="cn(checkboxIndicator, className)" v-bind="{ ...props, ...$attrs, ...api?.getIndicatorProps() }">
<slot v-if="asChild" />
<div v-else>
<slot />
</div>
</Slot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/checkbox/CheckboxHiddenInput.vue">
{{ `
<script lang="ts" setup>
import type { Api } from "@zag-js/checkbox";
import { cn } from "@mykopkb/core/utils/cn";
import { checkboxHiddenInput } from "@mykopkb/core/styles/checkbox.styles";
import { inject } from "vue";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
const api = inject<Api>("checkboxApi");
</script>
<template>
<input :class="cn(checkboxHiddenInput, className)"
v-bind="{ ...props, ...$attrs, ...api?.getHiddenInputProps() }" />
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/checkbox/index.ts">
{{ `
export { default as CheckboxRoot } from "./CheckboxRoot.vue";
export { default as CheckboxLabel } from "./CheckboxLabel.vue";
export { default as CheckboxControl } from "./CheckboxControl.vue";
export { default as CheckboxIndicator } from "./CheckboxIndicator.vue";
export { default as CheckboxHiddenInput } from "./CheckboxHiddenInput.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import {
CheckboxRoot,
CheckboxLabel,
CheckboxControl,
} from "@/components/ui/checkbox";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<CheckboxRoot>
<CheckboxControl />
<CheckboxLabel>Accept terms and conditions</CheckboxLabel>
</CheckboxRoot>
` }}
</PreviewCode>
</div>
</template>
-74
View File
@@ -1,74 +0,0 @@
<script lang="ts" setup>
import { Input } from "@/components/ui/input";
import {
Preview,
SectionTitle,
SectionContent,
PreviewCode,
} from "@/components/docs";
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<Input class="w-84" type="email" placeholder="Email" />
</template>
<template #code>
<PreviewCode>
{{ `
<Input class="w-84" type="email" placeholder="Email" />
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/input/Input.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { input } from "@mykopkb/core/styles/input.styles";
const {
class: className,
type,
...props
} = defineProps<{
class?: string;
type?: string;
}>();
</script>
<template>
<input :type="type" :class="cn(input, className)" v-bind="{ ...props }" />
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/input/index.ts">
{{ `
export { default as Input } from "./Input.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import { Input } from "@/components/ui/input";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<Input class="w-84" type="email" placeholder="Email" />
` }}
</PreviewCode>
</div>
</template>
-369
View File
@@ -1,369 +0,0 @@
<script lang="ts" setup>
import { Map } from "@/components/ui/map";
import {
Preview,
SectionTitle,
SectionContent,
InstallPackage,
PreviewCode,
} from "@/components/docs";
const markersData = {
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: { name: "Cafe Berlin", type: "cafe" },
geometry: {
type: "Point" as const,
coordinates: [13.388, 52.517],
},
},
{
type: "Feature" as const,
properties: { name: "Restaurant Alex", type: "restaurant" },
geometry: {
type: "Point" as const,
coordinates: [13.39, 52.518],
},
},
{
type: "Feature" as const,
properties: { name: "Coffee House", type: "cafe" },
geometry: {
type: "Point" as const,
coordinates: [13.385, 52.515],
},
},
{
type: "Feature" as const,
properties: { name: "Pizza Place", type: "restaurant" },
geometry: {
type: "Point" as const,
coordinates: [13.392, 52.519],
},
},
{
type: "Feature" as const,
properties: { name: "Burger Joint", type: "restaurant" },
geometry: {
type: "Point" as const,
coordinates: [13.387, 52.516],
},
},
{
type: "Feature" as const,
properties: { name: "Starbucks", type: "cafe" },
geometry: {
type: "Point" as const,
coordinates: [13.391, 52.52],
},
},
{
type: "Feature" as const,
properties: { name: "Sushi Bar", type: "restaurant" },
geometry: {
type: "Point" as const,
coordinates: [13.395, 52.522],
},
},
{
type: "Feature" as const,
properties: { name: "Bakery", type: "cafe" },
geometry: {
type: "Point" as const,
coordinates: [13.383, 52.514],
},
},
{
type: "Feature" as const,
properties: { name: "Italian Restaurant", type: "restaurant" },
geometry: {
type: "Point" as const,
coordinates: [13.398, 52.525],
},
},
{
type: "Feature" as const,
properties: { name: "Tea House", type: "cafe" },
geometry: {
type: "Point" as const,
coordinates: [13.38, 52.512],
},
},
],
};
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
</template>
<template #code>
<PreviewCode>
{{ `
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>Install the following dependencies:</SectionContent>
<InstallPackage>add maplibre-gl</InstallPackage>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/map/Map.vue">
{{`
<${""}script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type MapOptions } from "maplibre-gl";
import { ref, onMounted } from "vue";
import { Button } from "@/components/ui/button";
import { Plus, Minus, MapPin, Compass, Expand, X } from "@lucide/vue";
import type { FeatureCollection, Point } from "geojson";
import { cn } from "@mykopkb/core/utils/cn";
import { map } from "@mykopkb/core/styles/map.styles";
const mapRef = ref();
const mapInstance = ref<maplibregl.Map>();
const isFullscreen = ref(false);
const {
class: className,
markers,
...props
} = defineProps<
Partial<MapOptions> & {
class?: string;
markers?: FeatureCollection<Point>;
}
>();
onMounted(() => {
mapInstance.value = new maplibregl.Map({
...Object.fromEntries(Object.entries(props).filter(([_, value]) => value)),
style: "https://tiles.openfreemap.org/styles/positron",
container: mapRef.value,
});
markers &&
mapInstance.value.on("load", () => {
// Add source with clustering
mapInstance.value!.addSource("markers", {
type: "geojson",
data: markers,
cluster: true,
clusterMaxZoom: 14, // Max zoom for clustering
clusterRadius: 50, // Cluster radius in pixels
});
// Layer for clusters (circles)
mapInstance.value!.addLayer({
id: "clusters",
type: "circle",
source: "markers",
filter: ["has", "point_count"],
paint: {
"circle-color": [
"step",
["get", "point_count"],
"#cccccc", // Color for < 5 points
5,
"#cccccc", // Color for 5-10 points
10,
"#cccccc", // Color for > 10 points
],
"circle-radius": [
"step",
["get", "point_count"],
20, // Radius for < 5 points
5,
30, // Radius for 5-10 points
10,
40, // Radius for > 10 points
],
},
});
// Layer for cluster count numbers
mapInstance.value!.addLayer({
id: "cluster-count",
type: "symbol",
source: "markers",
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Open Sans Bold"],
"text-size": 12,
},
});
// Layer for individual points (unclustered)
mapInstance.value!.addLayer({
id: "unclustered-point",
type: "circle",
source: "markers",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": "#333333",
"circle-radius": 8,
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
},
});
// Click on cluster to zoom in
mapInstance.value!.on("click", "clusters", async (e) => {
const features = mapInstance.value!.queryRenderedFeatures(e.point, {
layers: ["clusters"],
});
const clusterId = features[0]?.properties.cluster_id;
const source = mapInstance.value!.getSource(
"markers"
) as maplibregl.GeoJSONSource;
try {
const zoom = await source.getClusterExpansionZoom(clusterId);
mapInstance.value!.easeTo({
center: (features[0]?.geometry as any).coordinates,
zoom: zoom,
});
} catch (err) {
console.error("Error getting cluster expansion zoom:", err);
}
});
// Click on individual point to show popup
mapInstance.value!.on("click", "unclustered-point", (e) => {
const coordinates = (
e.features![0]?.geometry as any
).coordinates.slice();
const { name, type } = (e.features![0]?.properties || {}) as any;
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(\`<h3>\${name}</h3><p>\${type}</p>\`)
.addTo(mapInstance.value!);
});
// Change cursor on hover cluster/point
mapInstance.value!.on("mouseenter", "clusters", () => {
mapInstance.value!.getCanvas().style.cursor = "pointer";
});
mapInstance.value!.on("mouseleave", "clusters", () => {
mapInstance.value!.getCanvas().style.cursor = "";
});
mapInstance.value!.on("mouseenter", "unclustered-point", () => {
mapInstance.value!.getCanvas().style.cursor = "pointer";
});
mapInstance.value!.on("mouseleave", "unclustered-point", () => {
mapInstance.value!.getCanvas().style.cursor = "";
});
});
});
const zoomIn = () => {
mapInstance.value?.zoomIn();
};
const zoomOut = () => {
mapInstance.value?.zoomOut();
};
const resetNorth = () => {
mapInstance.value?.easeTo({ bearing: 0, pitch: 0 });
};
const locateMe = () => {
if (!navigator.geolocation) {
alert("Geolocation is not supported by your browser");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { longitude, latitude } = position.coords;
mapInstance.value?.flyTo({
center: [longitude, latitude],
zoom: 14,
});
new maplibregl.Marker({ color: "var(--color-foreground)" })
.setLngLat([longitude, latitude])
.addTo(mapInstance.value!);
},
(error) => {
alert("Failed to get location: " + error.message);
}
);
};
const toggleFullscreen = () => {
if (!mapRef.value) return;
if (!isFullscreen.value) {
if (mapRef.value.requestFullscreen) {
mapRef.value.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
isFullscreen.value = !isFullscreen.value;
};
</${""}script>
<template>
<div data-scope="map" data-part="root" ref="mapRef" :class="cn(map, className)">
<div data-scope="map" data-part="controls">
<Button data-scope="map" data-part="zoom-in" @click="zoomIn" variant="ghost" size="sm">
<Plus />
</Button>
<Button data-scope="map" data-part="zoom-out" @click="zoomOut" variant="ghost" size="sm">
<Minus />
</Button>
<Button data-scope="map" data-part="reset-north" @click="resetNorth" variant="ghost" size="sm">
<Compass />
</Button>
<Button data-scope="map" data-part="locate" @click="locateMe" variant="ghost" size="sm">
<MapPin />
</Button>
<Button data-scope="map" data-part="toggle-fullscreen" @click="toggleFullscreen" variant="ghost" size="sm">
<Expand v-if="!isFullscreen" />
<X v-else />
</Button>
</div>
</div>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/map/index.ts">
{{ `
export { default as Map } from "./Map.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import { Map } from "@/components/ui/map";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<Map class="w-full h-96" :center="[13.388, 52.517]" :zoom="9.5" :markers="markersData" />
` }}
</PreviewCode>
</div>
</template>
-653
View File
@@ -1,653 +0,0 @@
<script lang="ts" setup>
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Preview,
SectionTitle,
SectionContent,
PreviewCode,
} from "@/components/docs";
const invoices: {
invoice: string;
paymentStatus: string;
badge: "success" | "pending" | "danger";
totalAmount: string;
paymentMethod: string;
}[] = [
{
invoice: "INV001",
paymentStatus: "Paid",
badge: "success",
totalAmount: "$250.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV002",
paymentStatus: "Pending",
badge: "pending",
totalAmount: "$150.00",
paymentMethod: "PayPal",
},
{
invoice: "INV003",
paymentStatus: "Unpaid",
badge: "danger",
totalAmount: "$350.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV004",
paymentStatus: "Paid",
badge: "success",
totalAmount: "$450.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV005",
paymentStatus: "Paid",
badge: "success",
totalAmount: "$550.00",
paymentMethod: "PayPal",
},
{
invoice: "INV006",
paymentStatus: "Pending",
badge: "pending",
totalAmount: "$200.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV007",
paymentStatus: "Unpaid",
badge: "danger",
totalAmount: "$300.00",
paymentMethod: "Credit Card",
},
];
</script>
<template>
<div id="preview" class="-mt-20">
<Preview>
<template #preview>
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
{{ invoice.invoice }}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
{{ invoice.paymentStatus }}
</Badge>
</TableCell>
<TableCell>{{ invoice.paymentMethod }}</TableCell>
<TableCell class="text-right">
{{ invoice.totalAmount }}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<template #code>
<PreviewCode>
{{ `
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
\{\{ invoice.invoice \}\}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
\{\{ invoice.paymentStatus \}\}
</Badge>
</TableCell>
<TableCell>\{\{ invoice.paymentMethod \}\}</TableCell>
<TableCell class="text-right">
\{\{ invoice.totalAmount \}\}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
` }}
</PreviewCode>
</template>
</Preview>
</div>
<div id="installation">
<SectionTitle>Installation</SectionTitle>
<SectionContent>
Copy and paste the following code into your project.
</SectionContent>
<PreviewCode title="components/ui/table/Table.vue">
{{`
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import {
tableVariants,
type TableVariants,
} from "@mykopkb/core/styles/table.styles";
import { provide, computed } from "vue";
import TableContainer from "./TableContainer.vue";
const { class: className, ...props } = defineProps<
TableVariants & {
class?: string;
}
>();
provide(
"tableVariant",
computed(() => ({
variant: props.variant,
raised: props.raised,
}))
);
</script>
<template>
<TableContainer>
<table :class="cn(tableVariants({ variant, raised, className }), className)" v-bind="{ ...props }">
<slot />
</table>
</TableContainer>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableContainer.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableContainer } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<div :class="cn(tableContainer, className)" v-bind="{ ...props }">
<slot />
</div>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableHeader.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableHeader } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<thead :class="cn(tableHeader, className)" v-bind="{ ...props }">
<slot />
</thead>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableBody.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableBody } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<tbody :class="cn(tableBody, className)" v-bind="{ ...props }">
<slot />
</tbody>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableFooter.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableFooter } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<tfoot :class="cn(tableFooter, className)" v-bind="{ ...props }">
<slot />
</tfoot>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableHead.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableHead } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<th :class="cn(tableHead, className)" v-bind="{ ...props }">
<slot />
</th>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableRow.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableRow } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<tr :class="cn(tableRow, className)" v-bind="{ ...props }">
<slot />
</tr>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableCell.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableCellVariants } from "@mykopkb/core/styles/table.styles";
import { inject } from "vue";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
const variant = inject<
| {
variant?: "default" | "boxed" | null;
raised?: "single" | "double" | null;
}
| undefined
>("tableVariant", undefined);
</script>
<template>
<td :class="cn(tableCellVariants({ ...variant, className }), className)" v-bind="{ ...props }">
<slot />
</td>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/TableCaption.vue">
{{ `
<script lang="ts" setup>
import { cn } from "@mykopkb/core/utils/cn";
import { tableCaption } from "@mykopkb/core/styles/table.styles";
const { class: className, ...props } = defineProps<{
class?: string;
}>();
</script>
<template>
<caption :class="cn(tableCaption, className)" v-bind="{ ...props }">
<slot />
</caption>
</template>
` }}
</PreviewCode>
<PreviewCode title="components/ui/table/index.ts">
{{ `
export { default as Table } from "./Table.vue";
export { default as TableContainer } from "./TableContainer.vue";
export { default as TableHeader } from "./TableHeader.vue";
export { default as TableBody } from "./TableBody.vue";
export { default as TableFooter } from "./TableFooter.vue";
export { default as TableHead } from "./TableHead.vue";
export { default as TableRow } from "./TableRow.vue";
export { default as TableCell } from "./TableCell.vue";
export { default as TableCaption } from "./TableCaption.vue";
` }}
</PreviewCode>
<SectionContent>
Update the import paths to match your project setup.
</SectionContent>
</div>
<div id="usage">
<SectionTitle>Usage</SectionTitle>
<PreviewCode>
{{ `
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from "@/components/ui/table";
` }}
</PreviewCode>
<PreviewCode>
{{ `
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
\{\{ invoice.invoice \}\}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
\{\{ invoice.paymentStatus \}\}
</Badge>
</TableCell>
<TableCell>\{\{ invoice.paymentMethod \}\}</TableCell>
<TableCell class="text-right">
\{\{ invoice.totalAmount \}\}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
` }}
</PreviewCode>
</div>
<div id="variants">
<SectionTitle>Variants</SectionTitle>
<SectionContent>A collection of components you can use.</SectionContent>
<Preview>
<template #preview>
<Table variant="boxed">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
{{ invoice.invoice }}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
{{ invoice.paymentStatus }}
</Badge>
</TableCell>
<TableCell>{{ invoice.paymentMethod }}</TableCell>
<TableCell class="text-right">
{{ invoice.totalAmount }}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<template #code>
<PreviewCode>
{{ `
<Table variant="boxed">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
\{\{ invoice.invoice \}\}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
\{\{ invoice.paymentStatus \}\}
</Badge>
</TableCell>
<TableCell>\{\{ invoice.paymentMethod \}\}</TableCell>
<TableCell class="text-right">
\{\{ invoice.totalAmount \}\}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Table variant="boxed" raised="single">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
{{ invoice.invoice }}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
{{ invoice.paymentStatus }}
</Badge>
</TableCell>
<TableCell>{{ invoice.paymentMethod }}</TableCell>
<TableCell class="text-right">
{{ invoice.totalAmount }}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<template #code>
<PreviewCode>
{{ `
<Table variant="boxed" raised="single">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
\{\{ invoice.invoice \}\}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
\{\{ invoice.paymentStatus \}\}
</Badge>
</TableCell>
<TableCell>\{\{ invoice.paymentMethod \}\}</TableCell>
<TableCell class="text-right">
\{\{ invoice.totalAmount \}\}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
` }}
</PreviewCode>
</template>
</Preview>
<Preview>
<template #preview>
<Table variant="boxed" raised="double">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
{{ invoice.invoice }}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
{{ invoice.paymentStatus }}
</Badge>
</TableCell>
<TableCell>{{ invoice.paymentMethod }}</TableCell>
<TableCell class="text-right">
{{ invoice.totalAmount }}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</template>
<template #code>
<PreviewCode>
{{ `
<Table variant="boxed" raised="double">
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.invoice">
<TableCell class="font-medium">
\{\{ invoice.invoice \}\}
</TableCell>
<TableCell>
<Badge :variant="invoice.badge">
\{\{ invoice.paymentStatus \}\}
</Badge>
</TableCell>
<TableCell>\{\{ invoice.paymentMethod \}\}</TableCell>
<TableCell class="text-right">
\{\{ invoice.totalAmount \}\}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell :colspan="3">Total</TableCell>
<TableCell class="text-right">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
` }}
</PreviewCode>
</template>
</Preview>
</div>
</template>
+7 -277
View File
@@ -1,5 +1,4 @@
import type { Menu } from '@/core/types/menu'
import { authMenu } from '@/modules/auth'
import { userMenu } from '@/modules/user'
import { roleMenu } from '@/modules/role'
import { membershipApplicationMenu } from '@/modules/membership-application'
@@ -8,6 +7,12 @@ import { activityMenu } from '@/modules/activity'
export type { Menu }
const mainMenu: (string | Menu)[] = [
'Dashboard',
{
icon: 'CircleGauge',
title: 'Dashboards',
route_name: 'dashboard-overview-1',
},
'Umum',
...activityMenu,
'Teknologi Maklumat',
@@ -15,19 +20,7 @@ const mainMenu: (string | Menu)[] = [
'Pentadbiran',
...membershipApplicationMenu,
...userMenu,
'GENERAL REPORTS',
{
icon: 'CircleGauge',
title: 'Dashboards',
badge: 4,
sub_menu: [
{
icon: 'PanelBottomClose',
route_name: 'dashboard-overview-1',
title: 'Overview 1',
},
],
},
'E-Commerce',
{
icon: 'SquareKanban',
title: 'E-Commerce',
@@ -99,11 +92,6 @@ const mainMenu: (string | Menu)[] = [
],
},
'APPS',
{
icon: 'CircleGauge',
route_name: 'inbox',
title: 'Inbox',
},
{
icon: 'CircleGauge',
route_name: 'file-manager',
@@ -115,218 +103,11 @@ const mainMenu: (string | Menu)[] = [
route_name: 'point-of-sale',
title: 'Point of Sale',
},
{
icon: 'CircleGauge',
route_name: 'chat',
title: 'Chat',
badge: 3,
},
{
icon: 'CircleGauge',
route_name: 'post',
title: 'Post',
},
// {
// icon: 'CircleGauge',
// route_name: 'calendar',
// title: 'Calendar',
// },
'PAGES',
{
icon: 'CircleGauge',
title: 'Crud',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'crud-data-list',
title: 'Data List',
},
],
},
{
icon: 'CircleGauge',
title: 'Users',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'users-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'users-layout-2',
title: 'Layout 2',
},
{
icon: 'CircleGauge',
route_name: 'users-layout-3',
title: 'Layout 3',
},
],
},
{
icon: 'CircleGauge',
title: 'Pages',
sub_menu: [
{
icon: 'CircleGauge',
title: 'Wizards',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'wizard-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'wizard-layout-2',
title: 'Layout 2',
},
{
icon: 'CircleGauge',
route_name: 'wizard-layout-3',
title: 'Layout 3',
},
],
},
{
icon: 'CircleGauge',
title: 'Blog',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'blog-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'blog-layout-2',
title: 'Layout 2',
},
{
icon: 'CircleGauge',
route_name: 'blog-layout-3',
title: 'Layout 3',
},
],
},
{
icon: 'CircleGauge',
title: 'Pricing',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'pricing-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'pricing-layout-2',
title: 'Layout 2',
},
],
},
{
icon: 'CircleGauge',
title: 'Invoice',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'invoice-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'invoice-layout-2',
title: 'Layout 2',
},
],
},
{
icon: 'CircleGauge',
title: 'FAQ',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'faq-layout-1',
title: 'Layout 1',
},
{
icon: 'CircleGauge',
route_name: 'faq-layout-2',
title: 'Layout 2',
},
{
icon: 'CircleGauge',
route_name: 'faq-layout-3',
title: 'Layout 3',
},
],
},
],
},
'UI COMPONENTS',
{
icon: 'CircleGauge',
title: 'Base',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'slot',
title: 'Slot',
},
{
icon: 'CircleGauge',
route_name: 'box',
title: 'Box',
},
{
icon: 'CircleGauge',
route_name: 'scroll-area',
title: 'Scroll Area',
},
],
},
{
icon: 'CircleGauge',
title: 'Navigation',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'breadcrumb',
title: 'Breadcrumb',
},
{
icon: 'CircleGauge',
route_name: 'menu',
title: 'Menu',
},
{
icon: 'CircleGauge',
route_name: 'pagination',
title: 'Pagination',
},
{
icon: 'CircleGauge',
route_name: 'tabs',
title: 'Tabs',
},
],
},
{
icon: 'CircleGauge',
title: 'Forms',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'button',
title: 'Button',
},
{
icon: 'CircleGauge',
route_name: 'checkbox',
title: 'Checkbox',
},
{
icon: 'CircleGauge',
route_name: 'combobox',
@@ -337,21 +118,11 @@ const mainMenu: (string | Menu)[] = [
route_name: 'datepicker',
title: 'Datepicker',
},
{
icon: 'CircleGauge',
route_name: 'field',
title: 'Field',
},
{
icon: 'CircleGauge',
route_name: 'input',
title: 'Input',
},
{
icon: 'CircleGauge',
route_name: 'native-select',
title: 'Native Select',
},
{
icon: 'CircleGauge',
route_name: 'radio-group',
@@ -379,46 +150,10 @@ const mainMenu: (string | Menu)[] = [
},
],
},
{
icon: 'CircleGauge',
title: 'Data Display',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'accordion',
title: 'Accordion',
},
{
icon: 'CircleGauge',
route_name: 'avatar',
title: 'Avatar',
},
{
icon: 'CircleGauge',
route_name: 'badge',
title: 'Badge',
},
{
icon: 'CircleGauge',
route_name: 'carousel',
title: 'Carousel',
},
{
icon: 'CircleGauge',
route_name: 'table',
title: 'Table',
},
],
},
{
icon: 'CircleGauge',
title: 'Feedback',
sub_menu: [
{
icon: 'CircleGauge',
route_name: 'alert',
title: 'Alert',
},
{
icon: 'CircleGauge',
route_name: 'progress-circular',
@@ -471,11 +206,6 @@ const mainMenu: (string | Menu)[] = [
route_name: 'chart',
title: 'Chart',
},
{
icon: 'CircleGauge',
route_name: 'map',
title: 'Map',
},
],
},
]
+2 -2
View File
@@ -6,8 +6,8 @@ import { Button } from '@/components/ui/button'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { logout, resolvePostAuthRoute } from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import logoUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import logoUrl from '@/assets/images/logo.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const authStore = useAuthStore()
+1 -1
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { getForgotPasswordErrorMessage, requestForgotPassword } from '@/modules/auth'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
+3 -6
View File
@@ -13,7 +13,7 @@ import {
resolvePostAuthRoute,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const route = useRoute()
@@ -125,11 +125,8 @@ const appVersion = import.meta.env.VITE_APP_VERSION
<CheckboxLabel>Ingat saya</CheckboxLabel>
</CheckboxRoot>
</div>
<button
type="button"
class="opacity-70 hover:opacity-100"
@click="router.push({ name: 'forgot-password' })"
>
<button type="button" class="opacity-70 hover:opacity-100"
@click="router.push({ name: 'forgot-password' })">
Lupa Password?
</button>
</div>
+2 -4
View File
@@ -1,8 +1,6 @@
<script lang="ts" setup>
import logoUrl from '@/assets/images/logo.svg'
import illustrationUrl from '@/assets/images/illustration.svg'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { RouterLink } from 'vue-router'
const appName = import.meta.env.VITE_APP_NAME
@@ -12,7 +10,7 @@ const sections = [
{
id: 'pengenalan',
title: '1. Pengenalan',
placeholder: 'Selamat datang ke SUTERA 3.0. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua TDM, dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
placeholder: 'Selamat datang ke MyKOPKB. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua Koperasi Permodalan Kelantan Berhad (KOPKB), dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
},
{
id: 'data-dikumpul',
@@ -60,7 +58,7 @@ const sections = [
id: 'hubungi',
title: '10. Hubungi Kami',
placeholder:
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- KAPT MOHAMMAD EFANDY BIN JAFFARI (effandy.jaffari@army.mil.my)'
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- ISMAIL BIN MASSERAN (ismail@koppkb.com)'
}
];
+1 -1
View File
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { PasswordInput } from '@/components/ui/password-input'
import { getRegisterErrorMessage, register } from '@/modules/auth'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
+15 -48
View File
@@ -12,7 +12,7 @@ import {
requestForgotPassword,
resetPassword,
} from '@/modules/auth'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const route = useRoute()
const router = useRouter()
@@ -133,61 +133,28 @@ const onOtpInput = (event: Event) => {
</AlertRoot>
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleReset">
<Input
v-model="email"
class="box block min-w-full px-5 py-6 xl:min-w-md"
type="email"
placeholder="Email"
autocomplete="email"
required
/>
<Input
:model-value="otp"
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
placeholder="000000"
autocomplete="one-time-code"
required
@input="onOtpInput"
/>
<PasswordInput
v-model="password"
class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Kata laluan baharu"
autocomplete="new-password"
required
/>
<PasswordInput
v-model="passwordConfirmation"
class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Sahkan kata laluan baharu"
autocomplete="new-password"
required
/>
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
placeholder="Email" autocomplete="email" required />
<Input :model-value="otp"
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg" type="text"
inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" autocomplete="one-time-code"
required @input="onOtpInput" />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Kata laluan baharu" autocomplete="new-password" required />
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Sahkan kata laluan baharu" autocomplete="new-password" required />
<div class="mt-5 text-center xl:mt-10 xl:text-left">
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
:disabled="loading || !canSubmit">
{{ loading ? 'Menyimpan...' : 'Tetapkan Semula Kata Laluan' }}
</Button>
<Button
class="box mt-4 w-full px-4 py-5"
look="outline"
type="button"
:disabled="resendLoading || !email"
@click="handleResend"
>
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
:disabled="resendLoading || !email" @click="handleResend">
{{ resendLoading ? 'Menghantar...' : 'Hantar Semula Kod OTP' }}
</Button>
<Button
class="box mt-4 w-full px-4 py-5"
look="outline"
type="button"
@click="router.push({ name: 'login' })"
>
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
@click="router.push({ name: 'login' })">
Kembali ke Log Masuk
</Button>
</div>
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import logoUrl from '@/assets/images/logo.svg'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { RouterLink } from 'vue-router'
const appName = import.meta.env.VITE_APP_NAME
+2 -2
View File
@@ -12,8 +12,8 @@ import {
verifyEmail,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import logoUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import logoUrl from '@/assets/images/logo.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const route = useRoute()
const router = useRouter()
@@ -4,7 +4,7 @@ import * as select from '@zag-js/select'
import { RouterLink } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
@@ -27,16 +27,19 @@ import type {
MembershipApplicationHeirForm,
MembershipApplicationReferenceForm,
} from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
const MIN_STOCK_MONTHLY_CONTRIBUTION = 50
const INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION = 84
const MIN_FEE_MONTHLY_CONTRIBUTION = 30
const steps = [
{ id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'Hubungan & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Waris' },
{ id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' },
] as const
@@ -73,6 +76,31 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Lain-lain', value: 'Lain-lain' },
]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'INFRA QUEST SDN BHD',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad',
address: 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad',
address: 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
] as const
const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
@@ -94,6 +122,11 @@ function apiValueToLabel(options: SelectOption[], value: string | undefined): st
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
function getEmployerAddress(name: string): string {
return EMPLOYERS.find((employer) => employer.name === name)?.address ?? ''
}
function createEmptyReference(): MembershipApplicationReferenceForm {
return {
@@ -149,6 +182,7 @@ const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applic
const marriageStatusInitial = computed(() =>
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
)
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
function setGenderValue(details: { value: string[] }) {
form.applicant.gender = labelToApiValue(GENDER_OPTIONS, details.value[0])
@@ -160,6 +194,14 @@ function setMarriageStatusValue(details: { value: string[] }) {
delete fieldErrors['applicant.marriage_status']
}
function setEmployerValue(details: { value: string[] }) {
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
form.applicant.employer_name = employerName
form.applicant.employer_address = getEmployerAddress(employerName)
delete fieldErrors['applicant.employer_name']
delete fieldErrors['applicant.employer_address']
}
function setHeirRelationshipValue(index: number, details: { value: string[] }) {
const heir = form.heirs[index]
if (!heir) return
@@ -254,7 +296,7 @@ const stepTitle = computed(() => {
case 3:
return 'Maklumat Pekerjaan & Caruman'
case 4:
return 'Maklumat Waris'
return 'Maklumat Penama'
default:
return 'Dokumen & Pengesahan'
}
@@ -269,7 +311,7 @@ const stepDescription = computed(() => {
case 3:
return 'Masukkan maklumat pekerjaan dan caruman bulanan.'
case 4:
return 'Tambah sekurang-kurangnya satu waris.'
return 'Tambah sekurang-kurangnya satu Penama.'
default:
return 'Muat naik dokumen sokongan dan semak maklumat sebelum hantar.'
}
@@ -324,21 +366,45 @@ function validateStep(step: number): boolean {
requireField(
'applicant.stock_monthly_contribution',
form.applicant.stock_monthly_contribution,
'Caruman saham bulanan',
'Potongan modal syer minima',
)
const stockContribution = Number(form.applicant.stock_monthly_contribution)
if (
form.applicant.stock_monthly_contribution &&
(Number.isNaN(stockContribution) || stockContribution < MIN_STOCK_MONTHLY_CONTRIBUTION)
) {
setError(
'applicant.stock_monthly_contribution',
`Potongan modal syer minima mestilah sekurang-kurangnya RM${MIN_STOCK_MONTHLY_CONTRIBUTION}.`,
)
valid = false
}
requireField(
'applicant.fee_monthly_contribution',
form.applicant.fee_monthly_contribution,
'Caruman yuran bulanan',
'Potongan yuran bulanan',
)
const feeContribution = Number(form.applicant.fee_monthly_contribution)
if (
form.applicant.fee_monthly_contribution &&
(Number.isNaN(feeContribution) || feeContribution < MIN_FEE_MONTHLY_CONTRIBUTION)
) {
setError(
'applicant.fee_monthly_contribution',
`Potongan yuran bulanan mestilah sekurang-kurangnya RM${MIN_FEE_MONTHLY_CONTRIBUTION}.`,
)
valid = false
}
}
if (step === 4) {
form.heirs.forEach((heir, index) => {
requireField(`heirs.${index}.name`, heir.name, `Nama waris ${index + 1}`)
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP waris ${index + 1}`)
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan waris ${index + 1}`)
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon waris ${index + 1}`)
requireField(`heirs.${index}.name`, heir.name, `Nama Penama ${index + 1}`)
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP Penama ${index + 1}`)
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan Penama ${index + 1}`)
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon Penama ${index + 1}`)
})
}
@@ -355,6 +421,11 @@ function validateStep(step: number): boolean {
setError('documents.ic_copy', 'Salinan kad pengenalan diperlukan.')
valid = false
}
if (!form.documents.employer_letter) {
setError('documents.employer_letter', 'Surat pengesahan majikan diperlukan.')
valid = false
}
}
return valid
@@ -638,8 +709,23 @@ function stepLabelClass(stepId: number) {
<template v-else-if="currentStep === 3">
<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" />
<FieldLabel>Nama Majikan</FieldLabel>
<SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
:collection="employerCollection" :default-value="employerInitial" @value-change="setEmployerValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
<SelectValueText placeholder="Pilih majikan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.employer_name']">
{{ fieldErrors['applicant.employer_name'] }}
</FieldError>
@@ -653,7 +739,7 @@ function stepLabelClass(stepId: number) {
</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" />
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled />
<FieldError v-if="fieldErrors['applicant.employer_address']">
{{ fieldErrors['applicant.employer_address'] }}
</FieldError>
@@ -666,17 +752,27 @@ function stepLabelClass(stepId: number) {
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
<FieldLabel for="stock_monthly_contribution">Potongan Modal Syer Minima</FieldLabel>
<FieldDescription>
Minima RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
</FieldDescription>
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
min="0" step="0.01" />
:min="MIN_STOCK_MONTHLY_CONTRIBUTION" step="0.01" />
<FieldDescription class="mt-2">
Potongan RM{{ INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan adalah wajib bagi 6 bulan
pertama bagi menjelaskan modal syer minimum RM500. Anda boleh memilih potongan lebih tinggi. Selepas
RM500 dijelaskan, potongan boleh dikekalkan atau dikurangkan sehingga minima
RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
</FieldDescription>
<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>
<FieldLabel for="fee_monthly_contribution">Potongan Yuran Bulanan</FieldLabel>
<FieldDescription>Minima RM{{ MIN_FEE_MONTHLY_CONTRIBUTION }} setiap bulan.</FieldDescription>
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
min="0" step="0.01" />
:min="MIN_FEE_MONTHLY_CONTRIBUTION" step="0.01" />
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">
{{ fieldErrors['applicant.fee_monthly_contribution'] }}
</FieldError>
@@ -688,7 +784,7 @@ function stepLabelClass(stepId: number) {
<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>
<div class="font-medium">Penama</div>
<Button v-if="form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
@@ -742,10 +838,11 @@ function stepLabelClass(stepId: number) {
</Field>
</div>
</div>
<Button type="button" look="outline" @click="addHeir">
<!-- Penama hanya boleh 1 orang (disable in frontend)-->
<!-- <Button type="button" look="outline" @click="addHeir">
<Lucide icon="Plus" class="mr-2 size-4" />
Tambah Waris
</Button>
Tambah Penama
</Button> -->
</div>
</template>
@@ -806,7 +903,7 @@ function stepLabelClass(stepId: number) {
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_letter">Surat Pengesahan Majikan (Pilihan)</FieldLabel>
<FieldLabel for="employer_letter">Surat Pengesahan Majikan *</FieldLabel>
<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']
@@ -820,7 +917,7 @@ function stepLabelClass(stepId: number) {
<div><span class="opacity-70">Emel:</span> {{ form.applicant.email }}</div>
<div><span class="opacity-70">No. KP:</span> {{ form.applicant.ic_number }}</div>
<div><span class="opacity-70">Majikan:</span> {{ form.applicant.employer_name }}</div>
<div><span class="opacity-70">Bil. Waris:</span> {{ form.heirs.length }}</div>
<div><span class="opacity-70">Bil. Penama:</span> {{ form.heirs.length }}</div>
<div>
<span class="opacity-70">Pencadang:</span>
{{ form.references.proposer.name || '-' }}
@@ -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, FileText, Pencil } from '@lucide/vue'
import { CircleAlert, CircleCheck, Download, Eye, Pencil, Trash2 } from '@lucide/vue'
import {
AlertRoot,
AlertTitle,
@@ -21,9 +21,9 @@ import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import {
completeMembershipApplication,
deleteMembershipApplicationDocument,
downloadMembershipApplicationDocument,
fetchMembershipApplicationDocument,
generateMembershipApplicationResultLetter,
getMembershipApplication,
submitBoardReview,
submitManagementReview,
@@ -43,12 +43,15 @@ import type {
MembershipApplicationReviewDetail,
MembershipApplicationStatus,
} from '../types/membership-application.types'
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
import {
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
RESULT_LETTER_DOCUMENT_TYPE,
} from '../types/membership-application.types'
const WORKFLOW_STEPS = [
{ id: 1, label: 'Dihantar' },
{ id: 2, label: 'Semakan Pentadbiran' },
{ id: 3, label: 'Semakan Lembaga' },
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
{ id: 4, label: 'Makluman Keputusan' },
{ id: 5, label: 'Selesai' },
] as const
@@ -58,6 +61,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
photo: 'Gambar Passport',
salary_slip: 'Slip Gaji',
employer_letter: 'Surat Pengesahan Majikan',
admin_attachment: 'Lampiran Pentadbir',
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
}
@@ -82,19 +86,20 @@ const pendingAction = ref<
| null
>(null)
const downloadingDocumentId = ref<string | null>(null)
const deletingDocumentId = ref<string | null>(null)
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)
const deleteConfirmDialogOpen = ref(false)
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
SUBMITTED: 'Dihantar',
PENDING_BOARD: 'Menunggu Lembaga',
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
PENDING_NOTIFICATION: 'Menunggu Makluman',
COMPLETED: 'Selesai',
@@ -142,16 +147,14 @@ const showCompleteAction = computed(
application.value?.status === 'PENDING_NOTIFICATION',
)
const resultLetterDocument = computed(() =>
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
const canEditAdminAttachments = computed(
() =>
hasPermission('kemaskini permohonan keahlian') &&
application.value?.status !== 'COMPLETED',
)
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 resultLetterDocument = computed(() =>
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
)
const applicant = computed(() => application.value?.applicant ?? null)
@@ -167,8 +170,8 @@ const confirmDialogTitle = computed(() => {
if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS'
? 'Luluskan Semakan Lembaga?'
: 'Gagalkan Semakan Lembaga?'
? 'Luluskan?'
: 'Gagalkan?'
}
return 'Selesaikan Permohonan?'
@@ -179,17 +182,17 @@ const confirmDialogDescription = computed(() => {
if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED'
? 'Permohonan akan dihantar ke semakan lembaga.'
? 'Permohonan akan dihantar ke mesyuarat ALK.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.'
}
if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS'
? 'Permohonan akan dihantar ke peringkat makluman keputusan.'
: 'Permohonan akan ditandakan gagal semakan lembaga.'
: 'Permohonan akan ditandakan gagal mesyuarat ALK.'
}
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
return 'Surat keputusan akan dijana, dilampirkan dalam e-mel keputusan, dan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
})
function workflowStepButtonClass(stepId: number) {
@@ -242,6 +245,14 @@ const uploadedDocuments = computed(() =>
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
)
const applicantDocuments = computed(() =>
uploadedDocuments.value.filter((document) => document.type !== ADMIN_ATTACHMENT_DOCUMENT_TYPE),
)
const adminAttachments = computed(() =>
uploadedDocuments.value.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE),
)
function displayValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
return String(value)
@@ -309,7 +320,7 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Semakan Lembaga'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
return stage
}
@@ -358,6 +369,11 @@ function openConfirmAction(
| { type: 'board'; decision: BoardReviewDecision }
| { type: 'complete' },
) {
if (action.type === 'complete') {
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
}
pendingAction.value = action
confirmDialogOpen.value = true
}
@@ -365,6 +381,8 @@ function openConfirmAction(
function closeConfirmDialog() {
confirmDialogOpen.value = false
pendingAction.value = null
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
}
async function confirmPendingAction() {
@@ -372,8 +390,14 @@ async function confirmPendingAction() {
error.value = null
successMessage.value = null
boardMeetingReferenceError.value = null
if (pendingAction.value.type === 'complete') {
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
completeSubmitting.value = true
} else {
reviewSubmitting.value = true
@@ -395,7 +419,9 @@ async function confirmPendingAction() {
})
boardRemarks.value = ''
} else {
response = await completeMembershipApplication(applicationId.value)
response = await completeMembershipApplication(applicationId.value, {
board_meeting_reference: boardMeetingReference.value.trim(),
})
}
application.value = response.data
@@ -407,55 +433,15 @@ async function confirmPendingAction() {
if (validationErrors?.remarks?.[0]) {
error.value = validationErrors.remarks[0]
}
if (validationErrors?.board_meeting_reference?.[0]) {
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
}
} finally {
reviewSubmitting.value = false
completeSubmitting.value = false
}
}
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
@@ -475,6 +461,43 @@ async function handleDownloadDocument(document: MembershipApplicationDocumentDet
}
}
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
if (!application.value || !canEditAdminAttachments.value || deletingDocumentId.value) return
pendingDeleteDocument.value = document
deleteConfirmDialogOpen.value = true
}
function closeDeleteConfirmDialog() {
deleteConfirmDialogOpen.value = false
pendingDeleteDocument.value = null
}
async function confirmDeleteAdminAttachment() {
const document = pendingDeleteDocument.value
if (!application.value || !document || deletingDocumentId.value) return
deletingDocumentId.value = document.id
error.value = null
successMessage.value = null
try {
const response = await deleteMembershipApplicationDocument(application.value.id, document.id)
application.value = response.data
if (previewDocument.value?.id === document.id) {
handlePreviewOpenChange(false)
}
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
closeDeleteConfirmDialog()
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
} finally {
deletingDocumentId.value = null
}
}
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
if (!application.value) return
@@ -649,51 +672,31 @@ onUnmounted(() => {
<Box v-if="showCompleteAction" class="p-5 sm:p-6">
<div class="font-medium">Makluman Keputusan</div>
<p class="mt-1 text-sm opacity-70">
Hantar e-mel keputusan kepada pemohon
<span v-if="application.board_result === 'PASS'"> dan cipta akaun ahli</span>.
Jana surat keputusan, hantar e-mel dengan lampiran surat kepada pemohon
<span v-if="application.board_result === 'PASS'">, dan cipta akaun ahli</span>.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'complete' })">
Selesaikan & Hantar Makluman
Selesaikan, Jana Surat & Hantar E-mel
</Button>
</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">
<Box v-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"
<Button type="button" look="outline"
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
@click="handleViewDocument(resultLetterDocument)"
>
@click="handleViewDocument(resultLetterDocument)">
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button
type="button"
look="outline"
:disabled="downloadingDocumentId === resultLetterDocument.id"
@click="handleDownloadDocument(resultLetterDocument)"
>
<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>
@@ -722,7 +725,7 @@ onUnmounted(() => {
<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
Penama
</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"
@@ -834,7 +837,7 @@ onUnmounted(() => {
<div v-else class="space-y-4">
<div v-for="(heir, index) in application.heirs" :key="heir.id"
class="rounded-lg border border-foreground/10 p-4">
<div class="mb-4 font-medium">Waris {{ index + 1 }}</div>
<div class="mb-4 font-medium">Penama</div>
<div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama</FieldLabel>
@@ -890,30 +893,62 @@ onUnmounted(() => {
<TabsContent value="documents" class="mt-6">
<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 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>
<div class="mt-1 text-sm opacity-70">
{{ document.name }} · {{ formatFileSize(document.file_size) }}
<template v-else>
<div v-if="applicantDocuments.length" class="space-y-3">
<div class="font-medium">Dokumen Pemohon</div>
<div v-for="document in applicantDocuments" :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>
<div class="mt-1 text-sm opacity-70">
{{ document.name }} · {{ formatFileSize(document.file_size) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === document.id"
@click="handleViewDocument(document)">
<Eye class="mr-2 size-4" />
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
</Button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === document.id"
@click="handleViewDocument(document)">
<Eye class="mr-2 size-4" />
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
</Button>
</div>
<div v-if="adminAttachments.length" class="mt-8 space-y-3">
<div class="font-medium">Lampiran Pentadbir</div>
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
<div class="mt-1 text-sm opacity-70">{{ formatFileSize(document.file_size) }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === document.id"
@click="handleViewDocument(document)">
<Eye class="mr-2 size-4" />
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
</Button>
<Button v-if="canEditAdminAttachments" type="button" look="outline" size="sm" variant="danger"
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
<Trash2 class="mr-2 size-4" />
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</div>
</div>
</div>
</template>
</TabsContent>
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
@@ -932,11 +967,8 @@ onUnmounted(() => {
{{ formatDateTime(review.reviewed_at) }}
</div>
</div>
<Badge
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
:look="reviewDecisionBadgeLook(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>
@@ -959,11 +991,18 @@ onUnmounted(() => {
</template>
<DialogRoot :open="confirmDialogOpen"
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
@openChange="(details) => { if (!details.open) closeConfirmDialog(); else confirmDialogOpen = details.open }">
<DialogContent>
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">{{ confirmDialogTitle }}</div>
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
<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="completeSubmitting"
@input="boardMeetingReferenceError = null" />
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
@@ -977,38 +1016,22 @@ onUnmounted(() => {
</DialogContent>
</DialogRoot>
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
<DialogRoot :open="deleteConfirmDialogOpen"
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
<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 class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
<div class="mt-2 opacity-70">
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
Batal
</DialogCloseTrigger>
<Button
class="w-32"
type="button"
variant="primary"
:disabled="generateSubmitting"
@click="confirmGenerateLetter"
>
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
@click="confirmDeleteAdminAttachment">
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</DialogContent>
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { CircleAlert, CircleCheck, Download, Eye, Plus } from '@lucide/vue'
import { CircleAlert, CircleCheck, Download, Eye, Plus, Trash2 } from '@lucide/vue'
import {
AlertRoot,
AlertTitle,
@@ -12,6 +12,7 @@ import {
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, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
@@ -31,6 +32,7 @@ import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import {
downloadMembershipApplicationDocument,
deleteMembershipApplicationDocument,
fetchMembershipApplicationDocument,
getMembershipApplication,
lookupMemberByIcNumber,
@@ -52,9 +54,10 @@ import type {
MembershipApplicationReviewDetail,
MembershipApplicationStatus,
} from '../types/membership-application.types'
import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.types'
import {
APPLICANT_DOCUMENT_UPLOAD_TYPES,
DOCUMENT_TYPE_LABELS,
DOCUMENT_UPLOAD_TYPES,
GENDER_OPTIONS,
MARRIAGE_STATUS_OPTIONS,
RELATIONSHIP_OPTIONS,
@@ -73,7 +76,7 @@ const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
const WORKFLOW_STEPS = [
{ id: 1, label: 'Dihantar' },
{ id: 2, label: 'Semakan Pentadbiran' },
{ id: 3, label: 'Semakan Lembaga' },
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
{ id: 4, label: 'Makluman Keputusan' },
{ id: 5, label: 'Selesai' },
] as const
@@ -91,11 +94,15 @@ const application = ref<MembershipApplicationDetail | null>(null)
const form = reactive<MembershipApplicationFormState>(createEmptyFormState())
const fieldErrors = reactive<Record<string, string>>({})
const downloadingDocumentId = ref<string | null>(null)
const deletingDocumentId = ref<string | null>(null)
const uploadingDocumentType = ref<DocumentUploadType | null>(null)
const uploadingAdminAttachment = ref(false)
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const deleteConfirmDialogOpen = ref(false)
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const referenceLookupLoading = reactive({
proposer: false,
@@ -131,17 +138,22 @@ const sortedReviews = computed(() => {
})
})
const documentsByType = computed(() => {
function buildDocumentsByType(types: readonly DocumentUploadType[]) {
const map: Partial<Record<DocumentUploadType, MembershipApplicationDocumentDetail>> = {}
application.value?.documents.forEach((document) => {
if (DOCUMENT_UPLOAD_TYPES.includes(document.type as DocumentUploadType)) {
if (types.includes(document.type as DocumentUploadType)) {
map[document.type as DocumentUploadType] = document
}
})
return map
})
}
const applicantDocumentsByType = computed(() => buildDocumentsByType(APPLICANT_DOCUMENT_UPLOAD_TYPES))
const adminAttachments = computed(() =>
application.value?.documents.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE) ?? [],
)
function getWorkflowProgress(status: MembershipApplicationStatus) {
switch (status) {
@@ -163,7 +175,7 @@ function getWorkflowProgress(status: MembershipApplicationStatus) {
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
SUBMITTED: 'Dihantar',
PENDING_BOARD: 'Menunggu Lembaga',
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
PENDING_NOTIFICATION: 'Menunggu Makluman',
COMPLETED: 'Selesai',
@@ -247,7 +259,7 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Semakan Lembaga'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
return stage
}
@@ -468,6 +480,91 @@ async function handleDocumentUpload(type: DocumentUploadType, event: Event) {
}
}
async function handleAdminAttachmentUpload(event: Event) {
if (!application.value || !canEdit.value || uploadingAdminAttachment.value) return
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length) return
const oversizedFile = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
if (oversizedFile) {
fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] =
`Saiz fail melebihi ${MAX_FILE_SIZE_MB}MB.`
return
}
delete fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]
uploadingAdminAttachment.value = true
error.value = null
successMessage.value = null
try {
let latestResponse = null
for (const file of files) {
latestResponse = await uploadMembershipApplicationDocument(
applicationId.value,
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
file,
)
application.value = latestResponse.data
}
successMessage.value =
files.length > 1
? `${files.length} lampiran pentadbir berjaya dimuat naik.`
: latestResponse?.message || 'Lampiran pentadbir berjaya dimuat naik.'
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors) {
setFieldErrors(validationErrors)
}
error.value = getApiErrorMessage(err, 'Gagal memuat naik lampiran pentadbir.')
} finally {
uploadingAdminAttachment.value = false
}
}
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
if (!application.value || !canEdit.value || deletingDocumentId.value) return
pendingDeleteDocument.value = document
deleteConfirmDialogOpen.value = true
}
function closeDeleteConfirmDialog() {
deleteConfirmDialogOpen.value = false
pendingDeleteDocument.value = null
}
async function confirmDeleteAdminAttachment() {
const document = pendingDeleteDocument.value
if (!application.value || !document || deletingDocumentId.value) return
deletingDocumentId.value = document.id
error.value = null
successMessage.value = null
try {
const response = await deleteMembershipApplicationDocument(applicationId.value, document.id)
application.value = response.data
if (previewDocument.value?.id === document.id) {
handlePreviewOpenChange(false)
}
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
closeDeleteConfirmDialog()
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
} finally {
deletingDocumentId.value = null
}
}
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
if (!application.value || downloadingDocumentId.value) return
@@ -635,7 +732,7 @@ onUnmounted(() => {
<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
Penama
</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"
@@ -770,13 +867,13 @@ onUnmounted(() => {
<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>
</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>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
@@ -805,7 +902,7 @@ onUnmounted(() => {
<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 class="mb-4 flex items-center justify-between">
<div class="font-medium">Waris {{ index + 1 }}</div>
<div class="font-medium">Penama</div>
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
@@ -822,7 +919,7 @@ onUnmounted(() => {
<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>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
@@ -855,10 +952,10 @@ onUnmounted(() => {
</Field>
</div>
</div>
<Button v-if="canEdit" type="button" look="outline" @click="addHeir">
<!-- <Button v-if="canEdit" type="button" look="outline" @click="addHeir">
<Plus class="mr-2 size-4" />
Tambah Waris
</Button>
Tambah Penama
</Button> -->
</div>
</TabsContent>
@@ -900,25 +997,27 @@ 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 APPLICANT_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>
<div v-if="documentsByType[type]" class="mt-1 text-sm opacity-70">
{{ documentsByType[type]?.name }} · {{ formatFileSize(documentsByType[type]?.file_size) }}
<div v-if="applicantDocumentsByType[type]" class="mt-1 text-sm opacity-70">
{{ applicantDocumentsByType[type]?.name }} ·
{{ formatFileSize(applicantDocumentsByType[type]?.file_size) }}
</div>
<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">
<div v-if="applicantDocumentsByType[type]" class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
:disabled="previewLoading && previewDocument?.id === applicantDocumentsByType[type]?.id"
@click="applicantDocumentsByType[type] && handleViewDocument(applicantDocumentsByType[type]!)">
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button type="button" look="outline" size="sm"
:disabled="downloadingDocumentId === documentsByType[type]?.id"
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
:disabled="downloadingDocumentId === applicantDocumentsByType[type]?.id"
@click="applicantDocumentsByType[type] && handleDownloadDocument(applicantDocumentsByType[type]!)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
@@ -926,7 +1025,7 @@ onUnmounted(() => {
</div>
<Field v-if="canEdit" class="mt-4">
<FieldLabel :for="`document-${type}`">
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
{{ applicantDocumentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
</FieldLabel>
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
@@ -935,6 +1034,51 @@ onUnmounted(() => {
</Field>
</div>
</div>
<div class="mt-8">
<div class="mb-4 font-medium">Lampiran Pentadbir</div>
<p class="mb-4 text-sm opacity-70">
Muat naik satu atau lebih dokumen tambahan semasa semakan permohonan. Setiap muat naik akan
menambah lampiran baharu.
</p>
<div v-if="adminAttachments.length" class="mb-4 space-y-3">
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
<div class="mt-1 text-sm opacity-70">{{ formatFileSize(document.file_size) }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === document.id"
@click="handleViewDocument(document)">
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
<Button v-if="canEdit" type="button" look="outline" size="sm" variant="danger"
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
<Trash2 class="mr-2 size-4" />
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</div>
</div>
<div v-else class="mb-4 text-sm opacity-70">Tiada lampiran pentadbir dimuat naik.</div>
<Field v-if="canEdit" class="rounded-lg border border-foreground/10 p-4">
<FieldLabel for="document-admin-attachment">Tambah Lampiran Pentadbir</FieldLabel>
<Input id="document-admin-attachment" type="file" accept=".pdf,.jpg,.jpeg,.png" multiple
:disabled="uploadingAdminAttachment" @change="handleAdminAttachmentUpload" />
<FieldError v-if="fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]">
{{ fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] }}
</FieldError>
<p v-if="uploadingAdminAttachment" class="mt-1 text-sm opacity-70">Memuat naik...</p>
</Field>
</div>
</TabsContent>
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
@@ -998,6 +1142,27 @@ onUnmounted(() => {
</div>
</template>
<DialogRoot :open="deleteConfirmDialogOpen"
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
<DialogContent>
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
<div class="mt-2 opacity-70">
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
Batal
</DialogCloseTrigger>
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
@click="confirmDeleteAdminAttachment">
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
</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"
@@ -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, FileText, Download } from '@lucide/vue'
import { CircleAlert, CircleCheck, Search, Eye, Pencil, Download } from '@lucide/vue'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import {
@@ -36,7 +36,6 @@ import { usePermissions } from '@/composables/usePermissions'
import {
batchCompleteMembershipApplications,
downloadMembershipApplicationDocument,
generateMembershipApplicationResultLetter,
} from '../services/membership-application.service'
import {
boardResultBadgeVariant,
@@ -46,7 +45,6 @@ import {
import type {
BatchCompleteFailedItem,
BatchCompleteResponse,
GenerateResultLetterResponse,
MembershipApplicationBoardResult,
MembershipApplicationListItem,
MembershipApplicationStatus,
@@ -57,7 +55,7 @@ type SelectOption = { label: string; value: string }
const STATUS_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Status', value: '' },
{ label: 'Dihantar', value: 'SUBMITTED' },
{ label: 'Menunggu Lembaga', value: 'PENDING_BOARD' },
{ label: 'Menunggu Keputusan ALK', value: 'PENDING_BOARD' },
{ label: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
{ label: 'Selesai', value: 'COMPLETED' },
@@ -105,18 +103,13 @@ 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(() =>
@@ -167,19 +160,28 @@ function toggleSelectAllOnPage(checked: boolean) {
function openBatchConfirm() {
if (!selectedIds.value.length) return
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
batchConfirmOpen.value = true
}
async function confirmBatchComplete() {
if (!selectedIds.value.length || batchSubmitting.value) return
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
batchSubmitting.value = true
batchSuccessMessage.value = null
batchFailedItems.value = []
boardMeetingReferenceError.value = null
error.value = null
try {
const response = await batchCompleteMembershipApplications(selectedIds.value)
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
if (response.success) {
batchSuccessMessage.value = response.message
@@ -196,6 +198,8 @@ async function confirmBatchComplete() {
const responseData = err.response.data as BatchCompleteResponse
batchFailedItems.value = responseData.data?.failed ?? []
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
} else {
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
}
@@ -213,7 +217,7 @@ const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
SUBMITTED: 'Dihantar',
PENDING_BOARD: 'Menunggu Lembaga',
PENDING_BOARD: 'Menunggu Keputusan ALK',
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
PENDING_NOTIFICATION: 'Menunggu Makluman',
COMPLETED: 'Selesai',
@@ -241,70 +245,10 @@ 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
@@ -384,13 +328,6 @@ 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>
@@ -482,11 +419,8 @@ const headers = computed<TableHeader[]>(() => {
</template>
<template #item.status="{ item }">
<Badge
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
class="whitespace-nowrap"
>
<Badge :variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
:look="statusBadgeLook((item as MembershipApplicationListItem).status)" class="whitespace-nowrap">
{{ statusLabel((item as MembershipApplicationListItem).status) }}
</Badge>
</template>
@@ -516,27 +450,10 @@ const headers = computed<TableHeader[]>(() => {
@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"
<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)"
>
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
<Download class="size-4" aria-hidden="true" />
</Button>
</div>
@@ -548,8 +465,16 @@ const headers = computed<TableHeader[]>(() => {
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
<div class="mt-2 opacity-70">
{{ selectedIds.length }} permohonan akan diselesaikan dan e-mel keputusan dihantar.
{{ selectedIds.length }} permohonan akan diselesaikan. Surat keputusan dijana dan e-mel dengan lampiran
surat dihantar.
</div>
<Field class="mt-5 text-left">
<FieldLabel for="batch-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input id="batch-board-meeting-reference" v-model="boardMeetingReference" type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="batchSubmitting"
@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="batchSubmitting">
@@ -562,45 +487,5 @@ const headers = computed<TableHeader[]>(() => {
</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="generateSubmitting"
@click="confirmGenerateLetter"
>
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</div>
</template>
@@ -157,6 +157,21 @@ export async function uploadMembershipApplicationDocument(
return data
}
export async function deleteMembershipApplicationDocument(
id: string,
documentId: string,
): Promise<MembershipApplicationUpdateResponse> {
const { data } = await api.delete<MembershipApplicationUpdateResponse>(
`/v1/membership-applications/${id}/documents/${documentId}`,
)
if (!data.success) {
throw new Error(data.message ?? 'Failed to delete document')
}
return data
}
// Submit management review
export async function submitManagementReview(
id: string,
@@ -194,9 +209,11 @@ export async function submitBoardReview(
// Complete membership application
export async function completeMembershipApplication(
id: string,
payload: GenerateResultLetterPayload,
): Promise<MembershipApplicationReviewResponse> {
const { data } = await api.post<MembershipApplicationReviewResponse>(
`/v1/membership-applications/${id}/complete`,
payload,
)
if (!data.success) {
@@ -208,10 +225,14 @@ export async function completeMembershipApplication(
export async function batchCompleteMembershipApplications(
applicationIds: string[],
boardMeetingReference: string,
): Promise<BatchCompleteResponse> {
const { data } = await api.post<BatchCompleteResponse>(
'/v1/membership-applications/batch-complete',
{ application_ids: applicationIds },
{
application_ids: applicationIds,
board_meeting_reference: boardMeetingReference,
},
)
return data
@@ -81,6 +81,7 @@ export type MembershipApplicationStatus =
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
export const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment' as const
export interface MembershipApplicationApplicantSummary {
name: string
@@ -208,7 +209,12 @@ export type MembershipApplicationUpdateResponse = MembershipApplicationApiRespon
message: string
}
export type DocumentUploadType = 'ic_copy' | 'photo' | 'salary_slip' | 'employer_letter'
export type DocumentUploadType =
| 'ic_copy'
| 'photo'
| 'salary_slip'
| 'employer_letter'
| 'admin_attachment'
export type ManagementReviewDecision = 'APPROVED' | 'REJECTED'
export type BoardReviewDecision = 'PASS' | 'FAIL'
@@ -40,10 +40,26 @@ export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
photo: 'Gambar Passport',
salary_slip: 'Slip Gaji',
employer_letter: 'Surat Pengesahan Majikan',
admin_attachment: 'Lampiran Pentadbir',
}
export const DOCUMENT_UPLOAD_TYPES = ['ic_copy', 'photo', 'salary_slip', 'employer_letter'] as const
export type DocumentUploadType = (typeof DOCUMENT_UPLOAD_TYPES)[number]
export const APPLICANT_DOCUMENT_UPLOAD_TYPES = [
'ic_copy',
'photo',
'salary_slip',
'employer_letter',
] as const
export const ADMIN_DOCUMENT_UPLOAD_TYPES = ['admin_attachment'] as const
export const DOCUMENT_UPLOAD_TYPES = [
...APPLICANT_DOCUMENT_UPLOAD_TYPES,
...ADMIN_DOCUMENT_UPLOAD_TYPES,
] as const
export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPES)[number]
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
export function createSelectCollection(options: SelectOption[]) {
return select.collection({
+63 -6
View File
@@ -69,6 +69,33 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Freelance', value: 'Freelance' },
]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'Infra Quest Sdn Bhd (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address:
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address:
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
@@ -88,9 +115,12 @@ function apiValueToLabel(options: SelectOption[], value: string | null | undefin
}
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
const employmentTypeValue = ref<string[]>([])
const employmentTypeInitial = ref<string[]>([])
const companyNameValue = ref<string[]>([])
const companyNameInitial = ref<string[]>([])
function clearEmploymentFieldError(field: EmploymentFieldKey) {
delete employmentErrors[field]
@@ -140,6 +170,10 @@ const employmentTypeLabel = computed(() =>
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
const canAddEmployment = computed(() => !loadingEmployments.value && employments.value.length === 0)
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
function setEmploymentTypeValue(details: { value: string[] }) {
employmentTypeValue.value = details.value
clearEmploymentFieldError('employment_type')
@@ -147,9 +181,17 @@ function setEmploymentTypeValue(details: { value: string[] }) {
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
}
function setCompanyNameValue(details: { value: string[] }) {
companyNameValue.value = details.value
clearEmploymentFieldError('company_name')
employmentForm.company_name = details.value[0] ?? ''
}
function syncEmploymentSelectValues() {
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
employmentTypeInitial.value = [...employmentTypeValue.value]
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
companyNameInitial.value = [...companyNameValue.value]
}
function resetEmploymentForm() {
@@ -194,7 +236,7 @@ function validateEmploymentForm(): boolean {
let valid = true
if (!employmentForm.company_name.trim()) {
if (!companyNameValue.value[0]?.trim()) {
employmentErrors.company_name = 'Nama syarikat diperlukan.'
valid = false
}
@@ -441,7 +483,8 @@ onMounted(async () => {
Tiada pekerjaan direkodkan.
</div>
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveEmployment">
<form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
@submit.prevent="onSaveEmployment">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-900">
@@ -470,10 +513,24 @@ onMounted(async () => {
<FieldGroup>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="employment-company">Nama Syarikat</FieldLabel>
<Input id="employment-company" v-model="employmentForm.company_name" type="text"
placeholder="Nama syarikat" :aria-invalid="!!employmentErrors.company_name"
@input="clearEmploymentFieldError('company_name')" />
<FieldLabel>Nama Syarikat</FieldLabel>
<SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
:collection="companyNameCollection" :default-value="companyNameInitial" :disabled="savingEmployment"
@value-change="setCompanyNameValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
<SelectValueText placeholder="Pilih syarikat" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
</Field>
<Field>
+62 -105
View File
@@ -90,7 +90,10 @@ const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const relationshipValue = ref<string[]>([])
const relationshipInitial = ref<string[]>([])
const MAX_HEIRS = 1
const isEditingHeir = computed(() => editingHeirId.value !== null)
const hasReachedHeirLimit = computed(() => heirs.value.length >= MAX_HEIRS)
const showHeirForm = computed(() => isEditingHeir.value || !hasReachedHeirLimit.value)
function emptyHeirForm() {
return {
@@ -204,7 +207,7 @@ async function fetchHeirs() {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
text: getApiErrorMessage(error, 'Gagal memuatkan penama.'),
})
} finally {
loadingHeirs.value = false
@@ -224,6 +227,15 @@ function startEditHeir(heir: Heir) {
}
async function onSaveHeir() {
if (!isEditingHeir.value && hasReachedHeirLimit.value) {
await Swal.fire({
icon: 'info',
title: 'Had penama',
text: 'Hanya satu penama dibenarkan.',
})
return
}
if (!validateHeirForm()) {
return
}
@@ -238,7 +250,7 @@ async function onSaveHeir() {
: await createHeir(payload)
if (!res.success) {
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
throw new Error(res.message ?? 'Gagal menyimpan penama.')
}
await fetchHeirs()
@@ -248,7 +260,7 @@ async function onSaveHeir() {
toast: true,
position: 'top-end',
icon: 'success',
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
title: wasEditing ? 'Penama berjaya dikemas kini.' : 'Penama berjaya ditambah.',
showConfirmButton: false,
timer: 3000,
})
@@ -257,7 +269,7 @@ async function onSaveHeir() {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
text: getApiErrorMessage(error, 'Gagal menyimpan penama.'),
})
}
} finally {
@@ -268,7 +280,7 @@ async function onSaveHeir() {
async function onDeleteHeir(heir: Heir) {
const result = await Swal.fire({
icon: 'warning',
title: 'Padam pewaris?',
title: 'Padam penama?',
text: 'Tindakan ini tidak boleh dibatalkan.',
showCancelButton: true,
confirmButtonText: 'Padam',
@@ -283,7 +295,7 @@ async function onDeleteHeir(heir: Heir) {
const res = await deleteHeir(heir.id)
if (!res.success) {
throw new Error(res.message ?? 'Gagal memadam pewaris.')
throw new Error(res.message ?? 'Gagal memadam penama.')
}
if (editingHeirId.value === heir.id) {
@@ -296,7 +308,7 @@ async function onDeleteHeir(heir: Heir) {
toast: true,
position: 'top-end',
icon: 'success',
title: 'Pewaris berjaya dipadam.',
title: 'Penama berjaya dipadam.',
showConfirmButton: false,
timer: 3000,
})
@@ -304,7 +316,7 @@ async function onDeleteHeir(heir: Heir) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
text: getApiErrorMessage(error, 'Gagal memadam penama.'),
})
} finally {
deletingHeirId.value = null
@@ -323,23 +335,20 @@ onMounted(async () => {
<div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
<p class="mt-1 text-sm text-slate-500">
Urus maklumat pewaris anda.
Urus maklumat penama anda. Hanya satu penama dibenarkan.
</p>
</div>
</div>
<div v-if="loadingHeirs" class="text-sm text-slate-500">
Memuatkan pewaris...
Memuatkan penama...
</div>
<div v-else-if="heirs.length" class="space-y-3">
<div
v-for="heir in heirs"
:key="heir.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div v-for="heir in heirs" :key="heir.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ heir.name }}</span>
@@ -351,64 +360,47 @@ onMounted(async () => {
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
</div>
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="deletingHeirId === heir.id"
@click="startEditHeir(heir)"
>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
:disabled="deletingHeirId === heir.id" @click="startEditHeir(heir)">
<Lucide class="mr-2 size-4" icon="Pencil" />
Kemaskini
</Button>
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none text-danger"
:disabled="deletingHeirId === heir.id"
@click="onDeleteHeir(heir)"
>
<Lucide
class="mr-2 size-4"
:icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingHeirId === heir.id }"
/>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
:disabled="deletingHeirId === heir.id" @click="onDeleteHeir(heir)">
<Lucide class="mr-2 size-4" :icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingHeirId === heir.id }" />
Padam
</Button>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pewaris direkodkan.
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
Tiada penama direkodkan.
</div>
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
<div v-if="hasReachedHeirLimit && !isEditingHeir"
class="rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm text-slate-600">
Had penama telah dicapai. Kemaskini atau padam penama sedia ada untuk membuat perubahan.
</div>
<form v-if="showHeirForm" class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-900">
{{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
{{ isEditingHeir ? 'Kemaskini Penama' : 'Tambah Penama' }}
</h4>
<p class="mt-1 text-sm text-slate-500">
{{
isEditingHeir
? 'Kemas kini maklumat pewaris yang dipilih.'
: 'Tambah pewaris baharu ke profil anda.'
? 'Kemas kini maklumat penama yang dipilih.'
: 'Tambah penama baharu ke profil anda.'
}}
</p>
</div>
<div class="flex gap-2">
<Button
v-if="isEditingHeir"
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="savingHeir"
@click="resetHeirForm"
>
<Button v-if="isEditingHeir" type="button" variant="ghost" class="border border-foreground/15 shadow-none"
:disabled="savingHeir" @click="resetHeirForm">
Batal
</Button>
<Button type="submit" variant="primary" :disabled="savingHeir">
@@ -421,38 +413,21 @@ onMounted(async () => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="heir-name">Nama</FieldLabel>
<Input
id="heir-name"
v-model="heirForm.name"
type="text"
placeholder="Nama penuh"
:aria-invalid="!!heirErrors.name"
@input="clearHeirFieldError('name')"
/>
<Input id="heir-name" v-model="heirForm.name" type="text" placeholder="Nama penuh"
:aria-invalid="!!heirErrors.name" @input="clearHeirFieldError('name')" />
<FieldError v-if="heirErrors.name">{{ heirErrors.name }}</FieldError>
</Field>
<Field>
<FieldLabel for="heir-ic">No. Kad Pengenalan</FieldLabel>
<Input
id="heir-ic"
v-model="heirForm.ic_number"
type="text"
placeholder="No. kad pengenalan"
:aria-invalid="!!heirErrors.ic_number"
@input="clearHeirFieldError('ic_number')"
/>
<FieldLabel for="heir-ic-number">No. Kad Pengenalan</FieldLabel>
<Input id="heir-ic-number" v-model="heirForm.ic_number" type="text" placeholder="Contoh: 900101011234"
:aria-invalid="!!heirErrors.ic_number" @input="clearHeirFieldError('ic_number')" />
<FieldError v-if="heirErrors.ic_number">{{ heirErrors.ic_number }}</FieldError>
</Field>
<Field>
<FieldLabel>Hubungan</FieldLabel>
<SelectRoot
:key="`heir-relationship-${editingHeirId ?? 'new'}`"
class="w-full"
:collection="relationshipCollection"
:default-value="relationshipInitial"
:disabled="savingHeir"
@value-change="setRelationshipValue"
>
<SelectRoot :key="`heir-relationship-${editingHeirId ?? 'new'}`" class="w-full"
:collection="relationshipCollection" :default-value="relationshipInitial" :disabled="savingHeir"
@value-change="setRelationshipValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
<SelectValueText placeholder="Pilih hubungan" />
@@ -461,11 +436,7 @@ onMounted(async () => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
<SelectItem
v-for="item in relationshipCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -474,37 +445,23 @@ onMounted(async () => {
<FieldError v-if="heirErrors.relationship">{{ heirErrors.relationship }}</FieldError>
</Field>
<Field>
<FieldLabel for="heir-phone">No. Telefon</FieldLabel>
<Input
id="heir-phone"
v-model="heirForm.phone_number"
type="text"
placeholder="No. telefon"
:aria-invalid="!!heirErrors.phone_number"
@input="clearHeirFieldError('phone_number')"
/>
<FieldLabel for="heir-phone-number">No. Telefon</FieldLabel>
<Input id="heir-phone-number" v-model="heirForm.phone_number" type="text"
placeholder="Contoh: 0123456789" :aria-invalid="!!heirErrors.phone_number"
@input="clearHeirFieldError('phone_number')" />
<FieldError v-if="heirErrors.phone_number">{{ heirErrors.phone_number }}</FieldError>
</Field>
<Field class="md:col-span-2">
<FieldLabel for="heir-address">Alamat</FieldLabel>
<Textarea
id="heir-address"
v-model="heirForm.address"
placeholder="Alamat penuh"
class="resize-none"
:aria-invalid="!!heirErrors.address"
@input="clearHeirFieldError('address')"
/>
<Textarea id="heir-address" v-model="heirForm.address" rows="3" placeholder="Alamat penama"
:aria-invalid="!!heirErrors.address" @input="clearHeirFieldError('address')" />
<FieldError v-if="heirErrors.address">{{ heirErrors.address }}</FieldError>
</Field>
<Field class="md:col-span-2">
<CheckboxRoot
:checked="heirForm.is_primary"
:disabled="savingHeir"
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)"
>
<CheckboxRoot :checked="heirForm.is_primary" :disabled="savingHeir"
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)">
<CheckboxControl />
<CheckboxLabel>Pewaris utama</CheckboxLabel>
<CheckboxLabel>Penama utama</CheckboxLabel>
</CheckboxRoot>
</Field>
</div>
+136 -72
View File
@@ -1,28 +1,17 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import Swal from 'sweetalert2'
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
import {
CarouselRoot,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselItemGroup,
CarouselItem,
} from '@/components/ui/carousel'
import { Lucide } from '@/components/ui/lucide'
import { FileIcon } from '@/components/ui/file-icon'
import { Badge } from '@/components/ui/badge'
import logoUrl from '@/assets/images/logo-kopkb.svg'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listEmployments } from '@/modules/profile/services/employment.service'
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
import type { Employment } from '@/modules/profile/types/employment.types'
import { useAuthStore } from '@/stores/auth'
import MemberDigitalCard from '../components/MemberDigitalCard.vue'
import ProfileTab from './ProfileTab.vue'
import EmploymentTab from './EmploymentTab.vue'
import BankDetailTab from './BankDetailTab.vue'
@@ -34,12 +23,59 @@ const authStore = useAuthStore()
const uploadingImage = ref(false)
const imageInputRef = ref<HTMLInputElement | null>(null)
const imagePreviewUrl = ref<string | null>(null)
const employments = ref<Employment[]>([])
const companyName = computed(() => {
const currentEmployment = employments.value.find((employment) => employment.is_current)
return currentEmployment?.company_name ?? employments.value[0]?.company_name ?? null
})
const profileUrl = computed(() => {
const token = authStore.user?.public_profile_token
if (!token) return null
const baseUrl = (typeof window !== 'undefined'
? window.location.origin
: import.meta.env.VITE_APP_URL || ''
).replace(/\/$/, '')
return `${baseUrl}/v/${token}`
})
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
const STATUS_LABELS: Record<string, string> = {
active: 'Aktif',
pending: 'Menunggu',
inactive: 'Tidak Aktif',
}
const statusLabel = computed(() => {
const status = authStore.userStatus
if (!status) return '-'
return STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
})
const statusBadgeVariant = computed(() => {
if (authStore.isAccountActive) return 'success' as const
if (authStore.isAccountPending) return 'pending' as const
return 'secondary' as const
})
function formatDateLabel(value: string | null | undefined): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('ms-MY', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
const avatarSrc = computed(
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
)
@@ -98,10 +134,21 @@ async function onImageSelected(event: Event) {
}
}
async function fetchEmployments() {
try {
const res = await listEmployments()
employments.value = res.data
} catch {
employments.value = []
}
}
onMounted(async () => {
if (!authStore.user) {
await authStore.fetchSession()
}
await fetchEmployments()
})
</script>
@@ -113,9 +160,10 @@ onMounted(async () => {
<TabsRoot defaultValue="1">
<!-- BEGIN: Profile Info -->
<Box raised="single" class="mt-5 p-0">
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
<div class="flex flex-col border-b border-foreground/15 lg:flex-row">
<!-- Identity -->
<div class="flex flex-1 items-center justify-center p-5 lg:justify-start">
<div class="relative shrink-0" :class="{ 'opacity-60': uploadingImage }">
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
@@ -129,87 +177,103 @@ onMounted(async () => {
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
@change="onImageSelected" />
</div>
<div class="ml-5">
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
<div class="ml-5 min-w-0">
<div class="truncate text-lg font-medium sm:whitespace-normal">
{{ authStore.userName || '-' }}
</div>
<div v-if="authStore.userMemberType"
class="mt-1 truncate text-sm capitalize opacity-70 sm:whitespace-normal">
{{ authStore.userMemberType }}
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Badge :variant="statusBadgeVariant">{{ statusLabel }}</Badge>
<Badge v-if="authStore.userMemberNumber" look="outline" variant="secondary">
No. {{ authStore.userMemberNumber }}
</Badge>
</div>
</div>
</div>
<div
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
<!-- Contact & membership -->
<div class="flex-1 border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
<div class="text-center font-medium lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center lg:items-start">
<div class="flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Mail" />
<Lucide class="mr-2 size-4 shrink-0" icon="Mail" />
{{ displayValue(authStore.user?.email) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Phone" />
<Lucide class="mr-2 size-4 shrink-0" icon="Phone" />
{{ displayValue(authStore.user?.phone_number) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="IdCard" />
<Lucide class="mr-2 size-4 shrink-0" icon="IdCard" />
{{ displayValue(authStore.user?.ic_number) }}
</div>
</div>
</div>
<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-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" />
<div
class="pointer-events-none absolute right-4 top-1/2 size-10 -translate-y-1/2 rounded-md border border-white/20 bg-white/10" />
<div class="relative flex h-full flex-col justify-between">
<div class="flex items-start justify-between gap-3">
<img :src="logoUrl" alt="" class="h-7 w-auto brightness-0 invert sm:h-8" />
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
Kad Digital
</div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="text-center lg:text-left">
<div class="truncate text-base font-medium">
{{ displayValue(authStore.userPosition) }}
</div>
<div>
<div class="text-[10px] font-medium uppercase tracking-widest opacity-70">No. Anggota</div>
<div class="mt-1 font-mono text-2xl font-semibold tracking-[0.15em] sm:text-3xl">
{{ displayValue(authStore.userMemberNumber) }}
</div>
<div class="text-xs opacity-70">Jawatan</div>
</div>
<div class="text-center lg:text-left">
<div class="truncate text-base font-medium">
{{ displayValue(companyName) }}
</div>
<div class="flex items-end justify-between gap-3 border-t border-white/15 pt-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ authStore.userName || '-' }}</div>
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Nama</div>
</div>
<div class="shrink-0 text-right">
<div class="text-sm font-semibold">{{ displayValue(authStore.userMemberType) }}</div>
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Jenis Anggota</div>
</div>
<div class="text-xs opacity-70">Syarikat</div>
</div>
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
<div class="truncate text-base font-medium">
{{ formatDateLabel(authStore.userJoinDate) }}
</div>
<div class="text-xs opacity-70">Tarikh Sertai</div>
</div>
</div>
</div>
<!-- Digital card -->
<div
class="flex shrink-0 items-center justify-center border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
<MemberDigitalCard large :member-number="authStore.userMemberNumber" :member-name="authStore.userName"
:member-type="authStore.userMemberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="avatarSrc" />
</div>
</div>
<!-- Tabs title -->
<div class="px-5 py-4">
<TabsList class="w-full mb-0 flex justify-between">
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="1">
<Lucide class="mr-2 size-4" icon="User" /> Profil
<TabsList class="mb-0 flex w-full">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="1" aria-label="Profil">
<Lucide class="size-4 shrink-0 md:mr-2" icon="User" />
<span class="hidden md:inline">Profil</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="5" aria-label="Pekerjaan">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Briefcase" />
<span class="hidden md:inline">Pekerjaan</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="3" aria-label="Bank">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Banknote" />
<span class="hidden md:inline">Bank</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="6" aria-label="Penama">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Users" />
<span class="hidden md:inline">Penama</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="2" aria-label="Kata Laluan">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Lock" />
<span class="hidden md:inline">Kata Laluan</span>
</TabsTrigger>
</TabsList>
</div>
@@ -230,7 +294,7 @@ onMounted(async () => {
<TabsContent value="2" class="mt-8">
<ChangePasswordTab embedded />
</TabsContent>
<!-- Pewaris -->
<!-- Penama -->
<TabsContent value="6" class="mt-8">
<HeirTab embedded />
</TabsContent>
+5 -12
View File
@@ -13,16 +13,12 @@ defineProps<{
<div :class="embedded ? '' : 'mt-5'">
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
<p class="mt-1 text-sm text-slate-500">Senarai pewaris pengguna.</p>
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
<p class="mt-1 text-sm text-slate-500">Senarai penama pengguna.</p>
</div>
<div v-if="user.heirs?.length" class="space-y-3">
<div
v-for="heir in user.heirs"
:key="heir.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div v-for="heir in user.heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ heir.name }}</span>
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
@@ -34,11 +30,8 @@ defineProps<{
</div>
</div>
<div
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pewaris direkodkan.
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
Tiada penama direkodkan.
</div>
</Box>
</div>
@@ -146,7 +146,7 @@ onMounted(() => {
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
</TabsTrigger>
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="6">
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
<Lucide class="mr-2 size-4" icon="Users" /> Penama
</TabsTrigger>
</TabsList>
</div>
-2
View File
@@ -81,8 +81,6 @@ onMounted(() => {
<template>
<div>
<ThemeSwitcher />
<button v-for="tab in sideTabs" :key="tab.id" type="button" :aria-label="tab.label" :style="{
top: `calc(50% + ${tab.offsetRem}rem)`,
['--color' as any]: 'var(--color-primary)',
File diff suppressed because it is too large Load Diff
+8 -10
View File
@@ -351,7 +351,7 @@ const salesReportDate = ref<any[]>([
</div>
<div class="relative ms-auto flex-none">
<Donut2 class="w-[90px] h-[90px]" />
<div class="absolute start-0 top-0 flex h-full w-full items-center justify-center font-medium">
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
20%
</div>
</div>
@@ -378,7 +378,7 @@ const salesReportDate = ref<any[]>([
</div>
<div class="relative ms-auto flex-none">
<Donut2 class="w-[90px] h-[90px]" />
<div class="absolute start-0 top-0 flex h-full w-full items-center justify-center font-medium">
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
45%
</div>
</div>
@@ -445,16 +445,14 @@ const salesReportDate = ref<any[]>([
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/[.05] mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
alt="Midone - Tailwind Admin Dashboard Template" />
alt="MyKOPKB" />
</div>
<div class="px-8">
<div class="text-center text-xl font-medium">Welcome to Midone Admin!</div>
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
Premium admin dashboard template for all kinds <br />
of projects. With a unique and modern design, Midone offers the perfect
foundation to build professional applications with ease.
Sistem MyKOPKB adalah sistem yang membantu pengguna untuk menguruskan permohonan keahlian KOPKB.
</div>
</div>
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
@@ -472,9 +470,9 @@ const salesReportDate = ref<any[]>([
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
<div
class="w-full bg-primary/[.05] mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
alt="Midone - Tailwind Admin Dashboard Template" />
alt="MyKOPKB" />
</div>
<div class="w-full">
<div class="text-center text-xl font-medium">Example Request Information</div>
-266
View File
@@ -1,266 +0,0 @@
<script lang="ts" setup>
import fakers from '@/utils/faker'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import {
PopoverRoot,
PopoverTrigger,
PopoverPositioner,
PopoverContent,
} from '@/components/ui/popover'
</script>
<template>
<div class="mt-8 grid grid-cols-10 gap-6">
<div class="col-span-10 lg:col-span-2">
<h2 class="mr-auto mt-2 text-lg font-medium">Inbox</h2>
<!-- BEGIN: Inbox Menu -->
<Box class="mt-6">
<Button class="w-full" type="button" variant="primary">
<Lucide class="size-4" icon="Edit" /> Compose
</Button>
<div class="mt-6 flex flex-col gap-2 border-t border-dashed border-foreground/[.08] pt-5">
<a class="[&.active]:bg-foreground/5 [&.active]:border-foreground/10 active flex items-center rounded-md border border-transparent px-3 py-2"
href="">
<Lucide class="mr-2 size-4" icon="Mail" /> Inbox
<Badge class="ms-auto" look="outline" variant="warning"> 214 </Badge>
</a>
<a class="[&.active]:bg-foreground/5 [&.active]:border-foreground/10 flex items-center rounded-md border border-transparent px-3 py-2"
href="">
<Lucide class="mr-2 size-4" icon="Star" /> Marked
</a>
<a class="[&.active]:bg-foreground/5 [&.active]:border-foreground/10 flex items-center rounded-md border border-transparent px-3 py-2"
href="">
<Lucide class="mr-2 size-4" icon="Inbox" /> Draft
<Badge class="ms-auto" look="outline" variant="warning"> 21 </Badge>
</a>
<a class="[&.active]:bg-foreground/5 [&.active]:border-foreground/10 flex items-center rounded-md border border-transparent px-3 py-2"
href="">
<Lucide class="mr-2 size-4" icon="Send" /> Sent
</a>
<a class="[&.active]:bg-foreground/5 [&.active]:border-foreground/10 flex items-center rounded-md border border-transparent px-3 py-2"
href="">
<Lucide class="mr-2 size-4" icon="Trash" /> Trash
</a>
</div>
<div class="mt-4 border-t border-dashed border-foreground/[.08] pt-4">
<a class="flex items-center truncate px-3 py-2" href="">
<div
class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-pending)]">
</div>
Custom Work
</a>
<a class="mt-2 flex items-center truncate rounded-md px-3 py-2" href="">
<div
class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-success)]">
</div>
Important Meetings
</a>
<a class="mt-2 flex items-center truncate rounded-md px-3 py-2" href="">
<div
class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-warning)]">
</div>
Work
</a>
<a class="mt-2 flex items-center truncate rounded-md px-3 py-2" href="">
<div
class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-pending)]">
</div>
Design
</a>
<a class="mt-2 flex items-center truncate rounded-md px-3 py-2" href="">
<div
class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-danger)]">
</div>
Next Week
</a>
<a class="mt-2 flex items-center truncate rounded-md px-3 py-2" href="">
<Lucide class="mr-2 size-4" icon="Plus" /> Add New Label
</a>
</div>
</Box>
<!-- END: Inbox Menu -->
</div>
<div class="col-span-10 lg:col-span-8">
<!-- BEGIN: Inbox Filter -->
<div class="flex flex-col-reverse items-center sm:flex-row">
<div class="relative mr-auto mt-3 w-full sm:mt-0 sm:w-auto">
<Lucide class="absolute inset-y-0 left-0 z-10 my-auto ml-3 size-4 opacity-70" icon="Search" />
<Input class="w-full px-10 sm:w-64" type="text" placeholder="Search mail" />
<PopoverRoot>
<PopoverTrigger as-child>
<a class="absolute inset-y-0 right-0 mr-3 flex items-center" href="#">
<Lucide class="size-4 cursor-pointer opacity-70" icon="ChevronDown" />
</a>
</PopoverTrigger>
<PopoverPositioner>
<PopoverContent class="ml-2 w-[30rem]">
<div class="grid grid-cols-12 gap-4 gap-y-3 p-3">
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-1">From</FieldLabel>
<Input id="input-filter-1" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-2">To</FieldLabel>
<Input id="input-filter-2" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-3">Subject</FieldLabel>
<Input id="input-filter-3" type="text" placeholder="Important Meeting" />
</Field>
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-4">Has the Words</FieldLabel>
<Input id="input-filter-4" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-5">Doesn't Have</FieldLabel>
<Input id="input-filter-5" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-6">
<FieldLabel class="text-xs" for="input-filter-6">Size</FieldLabel>
<NativeSelect id="input-filter-6">
<NativeSelectOption value="10">10</NativeSelectOption>
<NativeSelectOption value="25">25</NativeSelectOption>
<NativeSelectOption value="35">35</NativeSelectOption>
<NativeSelectOption value="50">50</NativeSelectOption>
</NativeSelect>
</Field>
<div class="col-span-12 mt-3 flex items-center">
<Button look="outline" variant="secondary" class="ml-auto w-32">
Create Filter
</Button>
<Button class="ml-2 w-32" variant="primary"> Search </Button>
</div>
</div>
</PopoverContent>
</PopoverPositioner>
</PopoverRoot>
</div>
<div class="flex w-full sm:w-auto">
<Button class="mr-2" look="outline" variant="primary"> Start a Video Call </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 size-5 items-center justify-center">
<Lucide class="h-4 w-4" icon="Plus" />
</span>
</Button>
</Box>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="contacts">
<Lucide class="mr-2 h-4 w-4" icon="User" /> Contacts
</MenuItem>
<MenuItem value="settings">
<Lucide class="mr-2 h-4 w-4" icon="Settings" /> Settings
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
</div>
<!-- END: Inbox Filter -->
<!-- BEGIN: Inbox Content -->
<Box class="mt-5 p-0">
<div class="flex flex-col-reverse border-b border-foreground/15 px-5 py-6 opacity-70 sm:flex-row">
<div
class="-mx-5 mt-3 flex items-center border-t border-foreground/15 px-5 pt-5 sm:mx-0 sm:mt-0 sm:border-0 sm:px-0 sm:pt-0">
<CheckboxRoot>
<CheckboxControl />
</CheckboxRoot>
<MenuRoot class="ml-1" :positioning="{ placement: 'bottom-start' }">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5" icon="ChevronDown" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-32">
<MenuItem value="all">All</MenuItem>
<MenuItem value="none">None</MenuItem>
<MenuItem value="read">Read</MenuItem>
<MenuItem value="unread">Unread</MenuItem>
<MenuItem value="starred">Starred</MenuItem>
<MenuItem value="unstarred">Unstarred</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<a class="ml-5 flex size-5 items-center justify-center" href="#">
<Lucide class="size-4" icon="RefreshCw" />
</a>
<a class="ml-5 flex size-5 items-center justify-center" href="#">
<Lucide class="size-4" icon="MoreHorizontal" />
</a>
</div>
<div class="flex items-center sm:ml-auto">
<div>1 - 50 of 5,238</div>
<a class="ml-5 flex size-5 items-center justify-center" href="#">
<Lucide class="size-4" icon="ChevronLeft" />
</a>
<a class="ml-5 flex size-5 items-center justify-center" href="#">
<Lucide class="size-4" icon="ChevronRight" />
</a>
<a class="ml-5 flex size-5 items-center justify-center" href="#">
<Lucide class="size-4" icon="Settings" />
</a>
</div>
</div>
<div class="overflow-x-auto sm:overflow-x-visible">
<div v-for="(faker, index) in fakers" :key="index" :class="[
'transition duration-200 ease-in-out transform cursor-pointer inline-block sm:block border-b border-foreground/[.08] mx-px',
'hover:scale-[1.02] hover:relative hover:z-20 hover:shadow-md hover:border-0 hover:rounded',
!faker['trueFalse'][0] ? 'bg-foreground/5 opacity-90' : 'bg-background',
]">
<div class="flex px-5 py-3.5">
<div class="mr-5 flex w-72 flex-none items-center">
<CheckboxRoot>
<CheckboxControl />
</CheckboxRoot>
<a class="ml-4 flex size-5 flex-none items-center justify-center opacity-60" href="#">
<Lucide class="size-4" icon="Star" />
</a>
<a class="ml-2 flex size-5 flex-none items-center justify-center opacity-60" href="#">
<Lucide class="size-4" icon="Bookmark" />
</a>
<AvatarRoot class="ml-5 size-7 rounded-full">
<AvatarFallback>{{ faker['users'][0]!['name'].substring(0, 2) }}</AvatarFallback>
<AvatarImage :src="faker['photos'][0]" :alt="faker['users'][0]!['name']" />
</AvatarRoot>
<div :class="['ml-3 truncate', { 'font-medium': faker['trueFalse'][0] }]">
{{ faker['users'][0]!['name'] }}
</div>
</div>
<div class="w-64 truncate sm:w-auto">
<span :class="['ml-3 truncate', { 'font-medium': faker['trueFalse'][0] }]">
{{ faker['news'][0]!['superShortContent'] }}
</span>
{{ faker['news'][0]!['shortContent'] }}
</div>
<div :class="[
'pl-10 ml-auto whitespace-nowrap',
{ 'font-medium': faker['trueFalse'][0] },
]">
{{ faker['times'][0] }}
</div>
</div>
</div>
</div>
<div class="flex flex-col items-center px-5 py-6 text-center opacity-70 sm:flex-row sm:text-left">
<div>4.41 GB (25%) of 17 GB used Manage</div>
<div class="mt-2 sm:ml-auto sm:mt-0">Last account activity: 36 minutes ago</div>
</div>
</Box>
<!-- END: Inbox Content -->
</div>
</div>
</template>
-481
View File
@@ -1,481 +0,0 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import * as combobox from '@zag-js/combobox'
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 {
ComboboxRoot,
ComboboxControl,
ComboboxTrigger,
ComboboxContent,
ComboboxInput,
ComboboxItemGroup,
ComboboxItemGroupLabel,
ComboboxItem,
ComboboxItemText,
} from '@/components/ui/combobox'
import {
DatePickerRoot,
DatePickerControl,
DatePickerInput,
DatePickerTrigger,
DatePickerClearTrigger,
DatePickerPositioner,
DatePickerContent,
DatePickerYearSelect,
DatePickerMonthSelect,
DatePickerView,
DatePickerViewControl,
DatePickerPrevTrigger,
DatePickerViewTrigger,
DatePickerNextTrigger,
DatePickerRangeText,
DatePickerTable,
DatePickerTableHead,
DatePickerTableRow,
DatePickerTableHeader,
DatePickerTableBody,
DatePickerTableCell,
DatePickerTableCellTrigger,
DatePickerContext,
} from '@/components/ui/datepicker'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
TooltipRoot,
TooltipTrigger,
TooltipPositioner,
TooltipContent,
} from '@/components/ui/tooltip'
// Categories combobox data
const categoryData = [
{ label: 'Horror', code: 'horror' },
{ label: 'Sci-fi', code: 'sci-fi' },
{ label: 'Action', code: 'action' },
{ label: 'Drama', code: 'drama' },
{ label: 'Comedy', code: 'comedy' },
]
const categoryValue = ref(['Horror', 'Action'])
const categoryOptions = ref(categoryData)
const categoryCollection = computed(() =>
combobox.collection({
items: categoryOptions.value,
itemToValue: (item) => item.label,
}),
)
// Tags combobox data
const tagData = [
{ label: 'Leonardo DiCaprio', code: 'leonardo' },
{ label: 'Johnny Deep', code: 'johnny' },
{ label: 'Robert Downey, Jr', code: 'robert' },
{ label: 'Samuel L. Jackson', code: 'samuel' },
{ label: 'Morgan Freeman', code: 'morgan' },
]
const tagValue = ref(['Leonardo DiCaprio', 'Robert Downey, Jr'])
const tagOptions = ref(tagData)
const tagCollection = computed(() =>
combobox.collection({
items: tagOptions.value,
itemToValue: (item) => item.label,
}),
)
</script>
<template>
<div class="flex flex-col items-center sm:flex-row">
<h2 class="mr-auto text-lg font-medium">Add New Post</h2>
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
<MenuRoot class="mr-2">
<MenuTrigger as-child>
<Box as-child>
<Button variant="ghost" class="box flex items-center">
English
<Lucide class="ml-2 size-4" icon="ChevronDown" />
</Button>
</Box>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="english">
<Lucide class="mr-2 size-4" icon="Activity" />
<span class="truncate">English</span>
</MenuItem>
<MenuItem value="indonesian">
<Lucide class="mr-2 size-4" icon="Activity" />
<span class="truncate">Indonesian</span>
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<Box as-child>
<Button class="box ml-auto mr-2 flex items-center sm:ml-0" type="button" variant="ghost">
<Lucide class="size-4" icon="Eye" /> Preview
</Button>
</Box>
<MenuRoot class="w-auto">
<MenuTrigger as-child>
<Button class="box flex items-center" variant="primary" look="outline">
<Lucide class="size-4" icon="Save" />
Save
<Lucide class="size-4" icon="ChevronDown" />
</Button>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="new-post">
<Lucide class="mr-2 size-4" icon="FileText" /> As New Post
</MenuItem>
<MenuItem value="draft">
<Lucide class="mr-2 size-4" icon="FileText" /> As Draft
</MenuItem>
<MenuItem value="export-pdf">
<Lucide class="mr-2 size-4" icon="FileText" /> Export to PDF
</MenuItem>
<MenuItem value="export-word">
<Lucide class="mr-2 size-4" icon="FileText" /> Export to Word
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
</div>
<div class="mt-5 grid grid-cols-12 gap-8">
<!-- BEGIN: Post Content -->
<div class="col-span-12 lg:col-span-8">
<Box raised="single">
<Input type="text" placeholder="Title" />
</Box>
<Box class="mt-8">
<TabsRoot defaultValue="content">
<TabsList class="w-full">
<TabsTrigger
class="w-full sm:w-1/3 inline-flex items-center justify-center"
value="content"
>
<Lucide class="mr-2 size-4" icon="FileText" /> Content
</TabsTrigger>
<TabsTrigger
class="w-full sm:w-1/3 inline-flex items-center justify-center"
value="meta-title"
>
<Lucide class="mr-2 size-4" icon="Code" /> Meta Title
</TabsTrigger>
<TabsTrigger
class="w-full sm:w-1/3 inline-flex items-center justify-center"
value="keywords"
>
<Lucide class="mr-2 size-4" icon="AlignLeft" /> Keywords
</TabsTrigger>
</TabsList>
<TabsContent value="content" class="mt-3.5">
<div class="rounded-lg border border-foreground/15 p-5">
<div class="flex items-center border-b border-foreground/15 pb-5 font-medium">
<Lucide class="mr-2 size-4" icon="ChevronDown" /> Text Content
</div>
<div class="mt-5">
<Textarea placeholder="Write your content here...">Content of the editor.</Textarea>
</div>
</div>
<div class="mt-5 rounded-lg border border-foreground/15 p-5">
<div class="flex items-center border-b border-foreground/15 pb-5 font-medium">
<Lucide class="mr-2 size-4" icon="ChevronDown" /> Caption & Images
</div>
<div class="mt-5">
<Field>
<FieldLabel for="post-form-7">Caption</FieldLabel>
<Input id="post-form-7" type="text" placeholder="Write caption" />
</Field>
<Field class="mt-3">
<FieldLabel>Upload Image</FieldLabel>
<div class="rounded-xl border-2 border-dashed pt-4 border-foreground/15">
<div class="flex flex-wrap px-4">
<div
v-for="(faker, index) in fakers.slice(0, 4)"
:key="index"
class="image-fit relative mb-5 mr-5 h-24 w-24 cursor-pointer"
>
<img
class="rounded-xl"
:src="faker.images[0]"
alt="Midone - Tailwind Admin Dashboard Template"
/>
<TooltipRoot>
<TooltipTrigger as-child>
<div
class="bg-(--color)/80 border-(--color) text-medium absolute right-0 top-0 -mr-2 -mt-2 flex size-5 cursor-pointer items-center justify-center rounded-full text-white [--color:var(--color-danger)]"
>
<Lucide class="size-4" icon="X" />
</div>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>Remove this image?</TooltipContent>
</TooltipPositioner>
</TooltipRoot>
</div>
</div>
<div class="relative flex cursor-pointer items-center px-4 pb-4">
<Lucide class="mr-2 size-4" icon="Image" />
<span class="text-primary mr-1">Upload a file</span>
or drag and drop
<Input class="absolute left-0 top-0 h-full w-full opacity-0" type="file" />
</div>
</div>
</Field>
</div>
</div>
</TabsContent>
</TabsRoot>
</Box>
</div>
<!-- END: Post Content -->
<!-- BEGIN: Post Info -->
<div class="col-span-12 lg:col-span-4">
<Box class="box p-5">
<Field>
<FieldLabel>Written By</FieldLabel>
<MenuRoot>
<MenuTrigger as-child>
<Button variant="ghost" class="border border-foreground/15 w-full">
<AvatarRoot class="mr-1 size-6 rounded-full">
<AvatarFallback>{{
fakers[0]!['users'][0]!['name'].substring(0, 2)
}}</AvatarFallback>
<AvatarImage
:src="fakers[0]!['photos'][0]"
:alt="fakers[0]!['users'][0]!['name']"
/>
</AvatarRoot>
<div class="truncate">{{ fakers[0]!['users'][0]!['name'] }}</div>
<Lucide class="ml-auto size-4" icon="ChevronDown" />
</Button>
</MenuTrigger>
<MenuPositioner>
<MenuContent>
<MenuItem
v-for="(faker, index) in fakers.slice(0, 5)"
:key="index"
:value="'author-' + index"
class="py-2"
>
<div class="size-5 image-fit me-1">
<img
class="rounded-full size-full"
:src="faker['photos'][0]"
alt="Midone - Tailwind Admin Dashboard Template"
/>
</div>
<div>{{ faker['users'][0]!['name'] }}</div>
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</Field>
<Field class="mt-3">
<FieldLabel>Post Date</FieldLabel>
<DatePickerRoot>
<DatePickerControl>
<DatePickerInput />
<DatePickerTrigger />
<DatePickerClearTrigger>Clear</DatePickerClearTrigger>
</DatePickerControl>
<Teleport to="body">
<DatePickerPositioner>
<DatePickerContent>
<DatePickerYearSelect />
<DatePickerMonthSelect />
<DatePickerView view="day">
<DatePickerContext v-slot="{ datePicker }">
<DatePickerViewControl>
<DatePickerPrevTrigger />
<DatePickerViewTrigger>
<DatePickerRangeText />
</DatePickerViewTrigger>
<DatePickerNextTrigger />
</DatePickerViewControl>
<DatePickerTable>
<DatePickerTableHead>
<DatePickerTableRow>
<DatePickerTableHeader
v-for="(weekDay, id) in datePicker?.weekDays"
:key="id"
>
{{ weekDay.short }}
</DatePickerTableHeader>
</DatePickerTableRow>
</DatePickerTableHead>
<DatePickerTableBody>
<DatePickerTableRow v-for="(week, id) in datePicker?.weeks" :key="id">
<DatePickerTableCell v-for="(day, id) in week" :key="id" :value="day">
<DatePickerTableCellTrigger>
{{ day.day }}
</DatePickerTableCellTrigger>
</DatePickerTableCell>
</DatePickerTableRow>
</DatePickerTableBody>
</DatePickerTable>
</DatePickerContext>
</DatePickerView>
<DatePickerView view="month">
<DatePickerContext v-slot="{ datePicker }">
<DatePickerViewControl>
<DatePickerPrevTrigger />
<DatePickerViewTrigger>
<DatePickerRangeText />
</DatePickerViewTrigger>
<DatePickerNextTrigger />
</DatePickerViewControl>
<DatePickerTable>
<DatePickerTableBody>
<DatePickerTableRow
v-for="(months, id) in datePicker?.getMonthsGrid({
columns: 4,
format: 'short',
})"
:key="id"
>
<DatePickerTableCell
v-for="(month, id) in months"
:key="id"
:value="month.value"
>
<DatePickerTableCellTrigger>{{
month.label
}}</DatePickerTableCellTrigger>
</DatePickerTableCell>
</DatePickerTableRow>
</DatePickerTableBody>
</DatePickerTable>
</DatePickerContext>
</DatePickerView>
<DatePickerView view="year">
<DatePickerContext v-slot="{ datePicker }">
<DatePickerViewControl>
<DatePickerPrevTrigger />
<DatePickerViewTrigger>
<DatePickerRangeText />
</DatePickerViewTrigger>
<DatePickerNextTrigger />
</DatePickerViewControl>
<DatePickerTable>
<DatePickerTableBody>
<DatePickerTableRow
v-for="(years, id) in datePicker?.getYearsGrid({
columns: 4,
})"
:key="id"
>
<DatePickerTableCell
v-for="(year, id) in years"
:key="id"
:value="year.value"
>
<DatePickerTableCellTrigger>{{
year.label
}}</DatePickerTableCellTrigger>
</DatePickerTableCell>
</DatePickerTableRow>
</DatePickerTableBody>
</DatePickerTable>
</DatePickerContext>
</DatePickerView>
</DatePickerContent>
</DatePickerPositioner>
</Teleport>
</DatePickerRoot>
</Field>
<Field class="mt-3">
<FieldLabel>Categories</FieldLabel>
<ComboboxRoot
:collection="categoryCollection"
:value="categoryValue"
@value-change="(details) => (categoryValue = details.value)"
@input-value-change="
({ inputValue }) => {
const filtered = categoryData.filter((item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()),
)
categoryOptions = filtered.length > 0 ? filtered : categoryData
}
"
class="w-full"
multiple
>
<ComboboxControl>
<ComboboxTrigger />
</ComboboxControl>
<ComboboxContent>
<ComboboxInput placeholder="Search categories..." />
<ComboboxItemGroup>
<ComboboxItemGroupLabel>Categories</ComboboxItemGroupLabel>
<ComboboxItem
v-for="item in categoryCollection.items"
:key="item.code"
:item="item"
>
<ComboboxItemText>{{ item.label }}</ComboboxItemText>
</ComboboxItem>
</ComboboxItemGroup>
</ComboboxContent>
</ComboboxRoot>
</Field>
<Field class="mt-3">
<FieldLabel>Tags</FieldLabel>
<ComboboxRoot
:collection="tagCollection"
:value="tagValue"
@value-change="(details) => (tagValue = details.value)"
@input-value-change="
({ inputValue }) => {
const filtered = tagData.filter((item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase()),
)
tagOptions = filtered.length > 0 ? filtered : tagData
}
"
class="w-full"
multiple
>
<ComboboxControl>
<ComboboxTrigger />
</ComboboxControl>
<ComboboxContent>
<ComboboxInput placeholder="Search tags..." />
<ComboboxItemGroup>
<ComboboxItemGroupLabel>Tags</ComboboxItemGroupLabel>
<ComboboxItem v-for="item in tagCollection.items" :key="item.code" :item="item">
<ComboboxItemText>{{ item.label }}</ComboboxItemText>
</ComboboxItem>
</ComboboxItemGroup>
</ComboboxContent>
</ComboboxRoot>
</Field>
<Field class="mt-3">
<FieldLabel>Published</FieldLabel>
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</Field>
<Field class="mt-3">
<FieldLabel>Show Author Name</FieldLabel>
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</Field>
</Box>
</div>
<!-- END: Post Info -->
</div>
</template>
-153
View File
@@ -1,153 +0,0 @@
<script lang="ts" setup>
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
PaginationRoot,
PaginationItem,
PaginationPrevTrigger,
PaginationNextTrigger,
PaginationEllipsis,
} from '@/components/ui/pagination'
import {
TooltipRoot,
TooltipTrigger,
TooltipPositioner,
TooltipContent,
} from '@/components/ui/tooltip'
import { Lucide } from '@/components/ui/lucide'
</script>
<template>
<div>
<h2 class="text-lg font-medium">Users Layout</h2>
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
<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 User </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="group">
<Lucide class="mr-2 h-4 w-4" icon="Users" /> Add Group
</MenuItem>
<MenuItem value="message">
<Lucide class="mr-2 h-4 w-4" icon="MessageCircle" /> Send Message
</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: Users Layout -->
<div v-for="(faker, index) in fakers.slice(0, 10)" :key="index" class="col-span-12 md:col-span-6">
<Box raised="single" class="p-0">
<div class="flex flex-col items-center border-b border-foreground/15 p-5 lg:flex-row">
<AvatarRoot class="size-24 bg-background rounded-full lg:mr-1 lg:size-12">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][2]" :alt="'Uploaded at ' + faker['dates'][2]" />
</AvatarRoot>
<div class="mt-3 text-center lg:ml-2 lg:mr-auto lg:mt-0 lg:text-left">
<a class="font-medium" href="">
{{ faker['users'][0]!['name'] }}
</a>
<div class="mt-0.5 text-xs opacity-70">
{{ faker['jobs'][0] }}
</div>
</div>
<div class="-ml-2 mt-3 flex lg:ml-0 lg:mt-0 lg:justify-end">
<TooltipRoot>
<TooltipTrigger as-child>
<a class="ml-2 flex h-8 w-8 items-center justify-center rounded-full border border-foreground/15"
href="">
<Lucide class="h-3 w-3 fill-current opacity-50" icon="Facebook" />
</a>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>Facebook</TooltipContent>
</TooltipPositioner>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<a class="ml-2 flex h-8 w-8 items-center justify-center rounded-full border border-foreground/15"
href="">
<Lucide class="h-3 w-3 fill-current opacity-50" icon="Twitter" />
</a>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>Twitter</TooltipContent>
</TooltipPositioner>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<a class="ml-2 flex h-8 w-8 items-center justify-center rounded-full border border-foreground/15"
href="">
<Lucide class="h-3 w-3 fill-current opacity-50" icon="Linkedin" />
</a>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>Linked In</TooltipContent>
</TooltipPositioner>
</TooltipRoot>
</div>
</div>
<div class="flex flex-wrap items-center justify-center p-5 lg:flex-nowrap">
<div class="mb-4 mr-auto w-full lg:mb-0 lg:w-1/2">
<div class="flex text-xs opacity-70">
<div class="mr-auto">Progress</div>
<div>20%</div>
</div>
<div class="mt-2 h-1 rounded-full bg-foreground/10">
<div class="h-full w-[20%] rounded-full bg-primary" />
</div>
</div>
<Button class="mr-2" size="sm" look="outline"> Message </Button>
<Button variant="primary" size="sm"> View Profile </Button>
</div>
</Box>
</div>
<!-- END: Users Layout -->
<!-- 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>
</div>
</template>
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts" setup>
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
PaginationRoot,
PaginationItem,
PaginationPrevTrigger,
PaginationNextTrigger,
PaginationEllipsis,
} from '@/components/ui/pagination'
import { Lucide } from '@/components/ui/lucide'
</script>
<template>
<div>
<h2 class="text-lg font-medium">Users Layout</h2>
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
<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 User </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="group">
<Lucide class="mr-2 h-4 w-4" icon="Users" /> Add Group
</MenuItem>
<MenuItem value="message">
<Lucide class="mr-2 h-4 w-4" icon="MessageCircle" /> Send Message
</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: Users Layout -->
<div v-for="(faker, index) in fakers" :key="index" class="col-span-12 md:col-span-6">
<Box raised="single">
<div class="flex flex-col items-center lg:flex-row">
<AvatarRoot class="size-24 bg-background rounded-full lg:mr-1 lg:size-12">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][2]" :alt="'Uploaded at ' + faker['dates'][2]" />
</AvatarRoot>
<div class="mt-3 text-center lg:ml-2 lg:mr-auto lg:mt-0 lg:text-left">
<a class="font-medium" href="">
{{ faker['users'][0]!['name'] }}
</a>
<div class="mt-0.5 text-xs opacity-70">
{{ faker['jobs'][0] }}
</div>
</div>
<div class="mt-4 flex lg:mt-0">
<Button class="mr-2" size="sm" look="outline"> View Profile </Button>
<Button variant="primary" size="sm"> Message </Button>
</div>
</div>
</Box>
</div>
<!-- END: Users Layout -->
<!-- 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>
</div>
</template>
-133
View File
@@ -1,133 +0,0 @@
<script lang="ts" setup>
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
PaginationRoot,
PaginationItem,
PaginationPrevTrigger,
PaginationNextTrigger,
PaginationEllipsis,
} from '@/components/ui/pagination'
import { Lucide } from '@/components/ui/lucide'
</script>
<template>
<div>
<h2 class="text-lg font-medium">Users Layout</h2>
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
<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 User </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="group">
<Lucide class="mr-2 h-4 w-4" icon="Users" /> Add Group
</MenuItem>
<MenuItem value="message">
<Lucide class="mr-2 h-4 w-4" icon="MessageCircle" /> Send Message
</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: Users Layout -->
<div v-for="(faker, index) in fakers.slice(0, 9)" :key="index" class="col-span-12 md:col-span-6 lg:col-span-4">
<Box raised="single" class="p-0">
<div class="flex items-start px-5 pt-5">
<div class="flex w-full flex-col items-center lg:flex-row">
<AvatarRoot class="size-14 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['photos'][0]" :alt="faker['users'][0]!['name']" />
</AvatarRoot>
<div class="mt-3 text-center lg:ml-4 lg:mt-0 lg:text-left">
<a class="font-medium" href="">
{{ faker['users'][0]!['name'] }}
</a>
<div class="mt-0.5 text-xs opacity-70">
{{ faker['jobs'][0] }}
</div>
</div>
</div>
<MenuRoot class="w-auto absolute right-0 top-0 mr-5 mt-3">
<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="edit">
<Lucide class="mr-2 h-4 w-4" icon="Edit" /> Edit
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 h-4 w-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
<div class="p-5 text-center lg:text-left">
<div>{{ faker['news'][0]!['shortContent'] }}</div>
<div class="mt-5 flex items-center justify-center opacity-70 lg:justify-start">
<Lucide class="mr-2 size-3.5" icon="Mail" />
{{ faker['users'][0]!['email'] }}
</div>
<div class="mt-1 flex items-center justify-center opacity-70 lg:justify-start">
<Lucide class="mr-2 size-3.5" icon="Instagram" />
{{ faker['users'][0]!['name'] }}
</div>
</div>
<div class="border-t border-foreground/15 p-5 text-center lg:text-right">
<Button class="mr-2" size="sm" look="outline"> Message </Button>
<Button variant="primary" size="sm"> View Profile </Button>
</div>
</Box>
</div>
<!-- END: Users Layout -->
<!-- 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>
</div>
</template>
-70
View File
@@ -1,70 +0,0 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
</script>
<template>
<div class="flex items-center">
<h2 class="mr-auto text-lg font-medium">Wizard Layout</h2>
</div>
<!-- BEGIN: Wizard Layout -->
<Box class="mt-5 py-10 sm:py-20">
<div class="flex justify-center">
<Button class="mx-2 size-14 rounded-full shadow-none"> 1 </Button>
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-14 rounded-full">
2
</Button>
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-14 rounded-full">
3
</Button>
</div>
<div class="mt-10 px-5">
<div class="text-center text-lg font-medium">Setup Your Account</div>
<div class="mt-2 text-center opacity-70">
To start off, please enter your username, email address and password.
</div>
</div>
<div class="mt-10 border-t border-foreground/10 px-5 pt-10 sm:px-20">
<div class="text-base font-medium">Profile Settings</div>
<div class="mt-5 grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-1">From</FieldLabel>
<Input id="input-wizard-1" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-2">To</FieldLabel>
<Input id="input-wizard-2" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-3">Subject</FieldLabel>
<Input id="input-wizard-3" type="text" placeholder="Important Meeting" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-4">Has the Words</FieldLabel>
<Input id="input-wizard-4" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-5">Doesn't Have</FieldLabel>
<Input id="input-wizard-5" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-6">Size</FieldLabel>
<NativeSelect id="input-wizard-6">
<NativeSelectOption>10</NativeSelectOption>
<NativeSelectOption>25</NativeSelectOption>
<NativeSelectOption>35</NativeSelectOption>
<NativeSelectOption>50</NativeSelectOption>
</NativeSelect>
</Field>
<div class="col-span-12 mt-5 flex items-center justify-center sm:justify-end">
<Button look="outline" class="w-32">Previous</Button>
<Button class="ml-2 w-32">Next</Button>
</div>
</div>
</div>
</Box>
<!-- END: Wizard Layout -->
</template>
-85
View File
@@ -1,85 +0,0 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
</script>
<template>
<div class="flex items-center">
<h2 class="mr-auto text-lg font-medium">Wizard Layout</h2>
</div>
<!-- BEGIN: Wizard Layout -->
<Box class="mt-5 py-10 sm:py-20">
<div class="grid gap-5 px-5 sm:px-20">
<div class="flex items-center">
<Button class="mx-2 size-12 rounded-full shadow-none"> 1 </Button>
<div class="text-primary ml-3 text-base font-medium opacity-100">Create New Account</div>
</div>
<div class="flex items-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
2
</Button>
<div class="ml-3 text-base opacity-70">Setup Your Profile</div>
</div>
<div class="flex items-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
3
</Button>
<div class="ml-3 text-base opacity-70">Setup Your Business Details</div>
</div>
<div class="flex items-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
4
</Button>
<div class="ml-3 text-base opacity-70">Setup Billing Account</div>
</div>
<div class="flex items-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
5
</Button>
<div class="ml-3 text-base opacity-70">Finalize your purchase</div>
</div>
</div>
<div class="mt-10 border-t border-foreground/10 px-5 pt-10 sm:px-20">
<div class="text-base font-medium">Profile Settings</div>
<div class="mt-5 grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-1">From</FieldLabel>
<Input id="input-wizard-1" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-2">To</FieldLabel>
<Input id="input-wizard-2" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-3">Subject</FieldLabel>
<Input id="input-wizard-3" type="text" placeholder="Important Meeting" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-4">Has the Words</FieldLabel>
<Input id="input-wizard-4" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-5">Doesn't Have</FieldLabel>
<Input id="input-wizard-5" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-6">Size</FieldLabel>
<NativeSelect id="input-wizard-6">
<NativeSelectOption>10</NativeSelectOption>
<NativeSelectOption>25</NativeSelectOption>
<NativeSelectOption>35</NativeSelectOption>
<NativeSelectOption>50</NativeSelectOption>
</NativeSelect>
</Field>
<div class="col-span-12 mt-5 flex items-center justify-center sm:justify-end">
<Button look="outline" class="w-32">Previous</Button>
<Button class="ml-2 w-32">Next</Button>
</div>
</div>
</div>
</Box>
<!-- END: Wizard Layout -->
</template>
-88
View File
@@ -1,88 +0,0 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
</script>
<template>
<div class="flex items-center">
<h2 class="mr-auto text-lg font-medium">Wizard Layout</h2>
</div>
<!-- BEGIN: Wizard Layout -->
<Box class="mt-5 py-10 sm:py-20">
<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-20 lg:flex-row before:lg:block">
<div class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button class="mx-2 size-12 rounded-full shadow-none"> 1 </Button>
<div class="text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32">
Create New Account
</div>
</div>
<div class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
2
</Button>
<div class="ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32">Setup Your Profile</div>
</div>
<div class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
3
</Button>
<div class="ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32">Setup Your Business Details</div>
</div>
<div class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
4
</Button>
<div class="ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32">Setup Billing Account</div>
</div>
<div class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button variant="ghost" class="bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full">
5
</Button>
<div class="ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32">Finalize your purchase</div>
</div>
</div>
<div class="mt-10 border-t border-foreground/10 px-5 pt-10 sm:px-20">
<div class="text-base font-medium">Profile Settings</div>
<div class="mt-5 grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-1">From</FieldLabel>
<Input id="input-wizard-1" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-2">To</FieldLabel>
<Input id="input-wizard-2" type="text" placeholder="example@gmail.com" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-3">Subject</FieldLabel>
<Input id="input-wizard-3" type="text" placeholder="Important Meeting" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-4">Has the Words</FieldLabel>
<Input id="input-wizard-4" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-5">Doesn't Have</FieldLabel>
<Input id="input-wizard-5" type="text" placeholder="Job, Work, Documentation" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="input-wizard-6">Size</FieldLabel>
<NativeSelect id="input-wizard-6">
<NativeSelectOption>10</NativeSelectOption>
<NativeSelectOption>25</NativeSelectOption>
<NativeSelectOption>35</NativeSelectOption>
<NativeSelectOption>50</NativeSelectOption>
</NativeSelect>
</Field>
<div class="col-span-12 mt-5 flex items-center justify-center sm:justify-end">
<Button look="outline" class="w-32">Previous</Button>
<Button class="ml-2 w-32">Next</Button>
</div>
</div>
</div>
</Box>
<!-- END: Wizard Layout -->
</template>