DONE: fix error on pagination where it cannot redirect to next pages, add onboarding_completed_at for new registered user, move dashboard page design into modules folder
This commit is contained in:
@@ -76,4 +76,7 @@ SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permiss
|
||||
## DO NOT RUN THIS BECAUSE IT WILL REMOVE ALL THE UNTAGGED FILES IN LOCAL
|
||||
```bash
|
||||
git reset --hard development # moves the current branch (e.g., main) to point exactly where development
|
||||
```
|
||||
```
|
||||
|
||||
## Public endpoint to apply membership:
|
||||
## http://localhost:5173/apply-membership
|
||||
@@ -56,6 +56,7 @@ class User extends Authenticatable
|
||||
'join_date',
|
||||
'birth_date',
|
||||
'birth_place',
|
||||
'onboarding_completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,7 @@ class User extends Authenticatable
|
||||
'join_date' => 'date',
|
||||
'birth_date' => 'date',
|
||||
'birth_place' => 'string',
|
||||
'onboarding_completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class UserResource extends JsonResource
|
||||
'join_date' => $this->join_date,
|
||||
'birth_date' => $this->birth_date,
|
||||
'birth_place' => $this->birth_place,
|
||||
'onboarding_completed_at' => $this->onboarding_completed_at,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'deleted_at' => $this->deleted_at,
|
||||
|
||||
+8
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\MembershipApplication\Emails;
|
||||
|
||||
use App\Notifications\Concerns\BuildsMailMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
@@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication;
|
||||
|
||||
class MembershipApplicationSubmittedNotification extends Notification
|
||||
{
|
||||
use BuildsMailMessage;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
@@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification
|
||||
{
|
||||
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Pengesahan Permohonan Keahlian')
|
||||
->markdown('membershipapplication::emails.submitted', [
|
||||
return $this->mailMessage(
|
||||
subject: 'Pengesahan Permohonan Keahlian',
|
||||
view: 'membershipapplication::emails.submitted',
|
||||
data: [
|
||||
'application' => $this->application,
|
||||
'applicant' => $this->application->applicant,
|
||||
'heirs' => $this->application->heirs,
|
||||
@@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification
|
||||
'salary_slip' => 'Slip Gaji',
|
||||
'employer_letter' => 'Surat Pengesahan Majikan',
|
||||
],
|
||||
'logoPath' => public_path('images/logo-kopkb.svg'),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@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')
|
||||
|
||||
# Pengesahan Permohonan Keahlian
|
||||
|
||||
@@ -74,6 +70,5 @@ Tiada dokumen.
|
||||
|
||||
Anda akan menerima emel apabila keputusan permohonan tersedia.
|
||||
|
||||
Terima kasih,<br>
|
||||
{{ config('app.name') }}
|
||||
@include('emails.partials.footer')
|
||||
@endcomponent
|
||||
|
||||
@@ -415,13 +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 = 84;
|
||||
$stockMonthlyContribution = (float) $applicant->stock_monthly_contribution;
|
||||
$monthlyShareInstallment = number_format(
|
||||
$stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution,
|
||||
2,
|
||||
'.',
|
||||
'',
|
||||
);
|
||||
|
||||
return [
|
||||
'application' => $application,
|
||||
@@ -434,7 +434,7 @@ class MembershipApplicationService
|
||||
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
|
||||
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
|
||||
'monthlyShareInstallment' => $monthlyShareInstallment,
|
||||
'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''),
|
||||
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
|
||||
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
|
||||
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('onboarding_completed_at')->nullable()->after('birth_place');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('onboarding_completed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -127,6 +127,17 @@ class UserController extends BaseCrudController
|
||||
]);
|
||||
}
|
||||
|
||||
public function completeOnboarding(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->userService->completeOnboarding($request->user());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new UserResource($user),
|
||||
'message' => 'Onboarding completed.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -35,6 +35,7 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
|
||||
|
||||
// User profile management routes
|
||||
Route::post('/profile', [UserController::class, 'updateProfile']);
|
||||
Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']);
|
||||
Route::put('/profile/password', [UserController::class, 'updatePassword']);
|
||||
Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download'])
|
||||
->name('profile.digital-card.download');
|
||||
|
||||
@@ -121,6 +121,17 @@ class UserService
|
||||
/**
|
||||
* @return array{}|array{error: string}
|
||||
*/
|
||||
public function completeOnboarding(User $user): User
|
||||
{
|
||||
if ($user->onboarding_completed_at === null) {
|
||||
$user->update(['onboarding_completed_at' => now()]);
|
||||
}
|
||||
|
||||
$user->load(['roles.permissions']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function updatePassword(User $user, string $currentPassword, string $newPassword): array
|
||||
{
|
||||
if (! Hash::check($currentPassword, $user->password)) {
|
||||
|
||||
+1
-1
@@ -124,6 +124,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')),
|
||||
'logo_path' => public_path('images/logo/logo-kopkb-letterhead.svg'),
|
||||
|
||||
];
|
||||
|
||||
@@ -7,30 +7,28 @@ import { normalizeProps, useMachine } from "@zag-js/vue";
|
||||
import { computed, provide } from "vue";
|
||||
import { paginationRoot } from "@mykopkb/core/styles/pagination.styles";
|
||||
|
||||
const {
|
||||
class: className,
|
||||
asChild = false,
|
||||
count,
|
||||
pageSize,
|
||||
siblingCount,
|
||||
...props
|
||||
} = defineProps<Partial<Props> & { class?: string; asChild?: boolean }>();
|
||||
const props = defineProps<Partial<Props> & { class?: string; asChild?: boolean }>();
|
||||
|
||||
const service = useMachine(pagination.machine, {
|
||||
...props,
|
||||
count,
|
||||
pageSize,
|
||||
siblingCount,
|
||||
id: crypto.randomUUID(),
|
||||
const paginationId = crypto.randomUUID();
|
||||
|
||||
const machineProps = computed(() => {
|
||||
const { class: _class, asChild: _asChild, id: _id, ...rest } = props;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: paginationId,
|
||||
};
|
||||
});
|
||||
|
||||
const service = useMachine(pagination.machine, machineProps);
|
||||
const api = computed(() => pagination.connect(service, normalizeProps));
|
||||
|
||||
provide("paginationApi", api);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot :class="cn(paginationRoot, className)" v-bind="{ ...props, ...$attrs, ...api.getRootProps() }">
|
||||
<slot v-if="asChild" />
|
||||
<Slot :class="cn(paginationRoot, props.class)" v-bind="{ ...$attrs, ...api.getRootProps() }">
|
||||
<slot v-if="props.asChild" />
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const paginationRoot = 'flex gap-1'
|
||||
export const paginationItem = [
|
||||
'h-10 px-4 py-2 inline-flex items-center justify-center rounded-xl cursor-pointer hover:bg-foreground/5',
|
||||
'data-[selected]:border data-[selected]:bg-background data-[selected]:border-foreground/10 data-[selected]:font-medium data-[selected]:shadow-md/5',
|
||||
'h-10 min-w-10 px-3 py-2 inline-flex items-center justify-center rounded-xl cursor-pointer text-foreground hover:bg-foreground/5',
|
||||
'data-[selected]:border data-[selected]:bg-primary/10 data-[selected]:border-primary/30 data-[selected]:text-primary data-[selected]:font-semibold data-[selected]:shadow-md/5',
|
||||
'data-[disabled]:opacity-70',
|
||||
]
|
||||
export const paginationPrevTrigger = paginationItem
|
||||
|
||||
@@ -94,7 +94,6 @@ const props = withDefaults(defineProps<{
|
||||
items: TableItem[];
|
||||
loading?: boolean;
|
||||
itemsPerPage?: number;
|
||||
page?: number;
|
||||
itemKey?: string;
|
||||
pagination?: PaginationData | null;
|
||||
showPagination?: boolean;
|
||||
@@ -106,7 +105,6 @@ const props = withDefaults(defineProps<{
|
||||
headers: () => [],
|
||||
loading: false,
|
||||
itemsPerPage: 10,
|
||||
page: 1,
|
||||
itemKey: 'id',
|
||||
pagination: null,
|
||||
showPagination: false,
|
||||
@@ -122,7 +120,6 @@ const itemsPerPageModel = defineModel<number>('items-per-page');
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:sort-by': [value: SortConfig[]];
|
||||
'update:page': [value: number];
|
||||
'update:items-per-page': [value: number];
|
||||
'update:column-widths': [value: Record<string, number>];
|
||||
'export-error': [error: Error];
|
||||
@@ -449,7 +446,6 @@ const setItemsPerPageValue = (details: { value: string[] }) => {
|
||||
|
||||
const handlePageChange = (details: { page: number }) => {
|
||||
pageModel.value = details.page;
|
||||
emit('update:page', details.page);
|
||||
};
|
||||
|
||||
const getItemKey = (item: TableItem, index: number) => {
|
||||
@@ -461,12 +457,6 @@ const currentPage = computed(
|
||||
() => pageModel.value ?? props.pagination?.current_page ?? 1,
|
||||
);
|
||||
|
||||
const isFirstPage = computed(() => currentPage.value <= 1);
|
||||
|
||||
const isLastPage = computed(
|
||||
() => currentPage.value >= (props.pagination?.last_page ?? 1),
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const showTableToolbar = computed(() => props.exportable || !!slots.toolbar);
|
||||
@@ -851,28 +841,21 @@ watch(sortBy, (newSort) => {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<PaginationRoot class="flex items-center gap-2" :count="pagination.total" :pageSize="pagination.per_page"
|
||||
:page="currentPage" :siblingCount="1" :onPageChange="handlePageChange">
|
||||
<PaginationPrevTrigger asChild>
|
||||
<Button size="sm" look="outline" variant="secondary" :disabled="isFirstPage">
|
||||
<ArrowLeft class="size-4" />
|
||||
</Button>
|
||||
<PaginationRoot class="flex items-center gap-2" :count="pagination.total" :page-size="pagination.per_page"
|
||||
:page="currentPage" :sibling-count="1" :on-page-change="handlePageChange">
|
||||
<PaginationPrevTrigger>
|
||||
<ArrowLeft class="size-4" />
|
||||
</PaginationPrevTrigger>
|
||||
<PaginationContext v-slot="{ pagination: paginationApi }">
|
||||
<template v-for="(pageItem, index) in paginationApi?.pages" :key="index">
|
||||
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }" asChild>
|
||||
<Button size="sm" :look="pageItem.value === currentPage ? 'filled' : 'outline'"
|
||||
:variant="pageItem.value === currentPage ? 'primary' : 'secondary'">
|
||||
{{ pageItem.value }}
|
||||
</Button>
|
||||
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }">
|
||||
{{ pageItem.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else :index="index" />
|
||||
</template>
|
||||
</PaginationContext>
|
||||
<PaginationNextTrigger asChild>
|
||||
<Button size="sm" look="outline" variant="secondary" :disabled="isLastPage">
|
||||
<ArrowRight class="size-4" />
|
||||
</Button>
|
||||
<PaginationNextTrigger>
|
||||
<ArrowRight class="size-4" />
|
||||
</PaginationNextTrigger>
|
||||
</PaginationRoot>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,13 @@ import { userMenu } from '@/modules/user'
|
||||
import { roleMenu } from '@/modules/role'
|
||||
import { membershipApplicationMenu } from '@/modules/membership-application'
|
||||
import { activityMenu } from '@/modules/activity'
|
||||
import { dashboardMenu } from '@/modules/dashboard/menu'
|
||||
|
||||
export type { Menu }
|
||||
|
||||
const mainMenu: (string | Menu)[] = [
|
||||
'Dashboard',
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
title: 'Dashboards',
|
||||
route_name: 'dashboard-overview-1',
|
||||
},
|
||||
...dashboardMenu,
|
||||
'Umum',
|
||||
...activityMenu,
|
||||
'Teknologi Maklumat',
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface AuthUser {
|
||||
join_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
onboarding_completed_at: string | null
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { dashboardLayoutRoutes } from './routes'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const dashboardMenu: Menu[] = [
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
route_name: 'dashboard-overview',
|
||||
title: 'Dashboard',
|
||||
}
|
||||
]
|
||||
+38
-3
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { today, getLocalTimeZone } from '@internationalized/date'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
@@ -55,16 +57,44 @@ import { OfficialStores } from '@/components/official-stores'
|
||||
import { WeeklyBestSellers } from '@/components/weekly-best-sellers'
|
||||
import { WeeklyTopProducts } from '@/components/weekly-top-products'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
const smartphone = ref(false)
|
||||
const photography = ref(false)
|
||||
const sport = ref(false)
|
||||
const onboardingDialog = ref(true)
|
||||
const onboardingDialog = ref(false)
|
||||
const salesReportDate = ref<any[]>([
|
||||
today(getLocalTimeZone()).subtract({ days: 7 }),
|
||||
today(getLocalTimeZone()),
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -435,7 +465,7 @@ const salesReportDate = ref<any[]>([
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="(details) => (onboardingDialog = details.open)">
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
@@ -503,6 +533,11 @@ const salesReportDate = ref<any[]>([
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Previous
|
||||
</CarouselPrevTrigger>
|
||||
<a class="text-primary flex items-center gap-3 font-medium cursor-pointer me-0 px-5"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Selesai
|
||||
<Lucide icon="MoveRight" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const onboardingDialog = ref(false)
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Selamat pagi'
|
||||
if (hour < 18) return 'Selamat petang'
|
||||
return 'Selamat malam'
|
||||
})
|
||||
|
||||
const availableModules = computed(() => [
|
||||
{
|
||||
title: 'Profil',
|
||||
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
|
||||
route: 'profile-overview-2',
|
||||
icon: 'User' as Icon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: 'Permohonan Keahlian',
|
||||
description: 'Urus dan semak permohonan keahlian KOPKB.',
|
||||
route: 'list-membership-applications',
|
||||
icon: 'ClipboardList' as Icon,
|
||||
visible: hasPermission('lihat permohonan keahlian'),
|
||||
},
|
||||
{
|
||||
title: 'Aktiviti',
|
||||
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
|
||||
route: 'list-activities',
|
||||
icon: 'CalendarDays' as Icon,
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Pengguna',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
visible: hasPermission('lihat pengguna'),
|
||||
},
|
||||
].filter((module) => module.visible))
|
||||
|
||||
const upcomingFeatures = [
|
||||
'Ringkasan statistik keahlian',
|
||||
'Carta dan laporan bulanan',
|
||||
'Notifikasi dan aktiviti terkini',
|
||||
'Papan pemuka pentadbir',
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
onboardingDialog.value = false
|
||||
await markOnboardingComplete()
|
||||
await router.push({ name: 'profile-overview-2' })
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AlertRoot class="mb-6" look="outline" variant="primary">
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Construction" />
|
||||
<AlertTitle>Sistem Dalam Pembangunan</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ appName }} {{ appVersion }} masih dalam fasa pembangunan. Beberapa fungsi papan pemuka
|
||||
belum tersedia. Sila gunakan modul sedia ada melalui menu sisi atau pautan di bawah.
|
||||
</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12">
|
||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div class="relative">
|
||||
<Badge variant="pending" class="mb-4">Beta</Badge>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
|
||||
Selamat datang ke {{ appName }}. Papan pemuka utama sedang dibangunkan — buat masa ini,
|
||||
anda boleh mula dengan mengemas kini profil atau mengurus permohonan keahlian.
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Modul Tersedia</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Box v-for="module in availableModules" :key="module.route"
|
||||
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="module.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-base font-medium">{{ module.title }}</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
|
||||
<RouterLink :to="{ name: module.route }">
|
||||
Pergi ke {{ module.title }}
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Akan Datang</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<p class="text-sm leading-relaxed opacity-70">
|
||||
Ciri-ciri berikut sedang dirancang untuk papan pemuka ini:
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="feature in upcomingFeatures" :key="feature" class="flex items-start gap-3 text-sm">
|
||||
<Lucide icon="CircleDashed" class="mt-0.5 size-4 shrink-0 text-primary/70" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Box class="mt-4 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<Lucide icon="Lightbulb" class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">Petua</div>
|
||||
<p class="mt-1 text-sm leading-relaxed opacity-70">
|
||||
Pastikan profil anda lengkap dan terkini. Maklumat yang
|
||||
tepat membantu proses kelulusan berjalan lebih lancar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<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/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="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<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">
|
||||
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Seterusnya
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<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/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="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Kembali
|
||||
</CarouselPrevTrigger>
|
||||
<a class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
|
||||
@click.prevent="finishOnboarding">
|
||||
<span>Selesai</span>
|
||||
<Lucide icon="Check" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const dashboardLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard-overview',
|
||||
component: () => import('./pages/DashboardOverview.vue'),
|
||||
meta: { title: 'Dashboard Overview', module: 'dashboard' },
|
||||
},
|
||||
]
|
||||
@@ -63,3 +63,8 @@ export async function updatePassword(payload: UpdatePasswordPayload): Promise<Up
|
||||
const { data } = await api.put<UpdatePasswordResponse>('/v1/profile/password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<UpdateProfileResponse> {
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile/onboarding/complete')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { userLayoutRoutes } from '@/modules/user'
|
||||
import { roleLayoutRoutes } from '@/modules/role'
|
||||
import { activityLayoutRoutes } from '@/modules/activity'
|
||||
import { dashboardLayoutRoutes } from '@/modules/dashboard'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -20,11 +21,7 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard-overview-1',
|
||||
component: () => import('../views/DashboardOverview1.vue'),
|
||||
},
|
||||
...dashboardLayoutRoutes,
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'categories',
|
||||
|
||||
Reference in New Issue
Block a user