DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -95,12 +95,7 @@ const sport = ref(false)
|
||||
<div class="mt-5">
|
||||
<Box>
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem
|
||||
as-child
|
||||
v-for="(faker, fakerKey) in _.take(fakers, 4)"
|
||||
:key="fakerKey"
|
||||
:index="fakerKey"
|
||||
>
|
||||
<CarouselItem as-child v-for="(faker, fakerKey) in _.take(fakers, 4)" :key="fakerKey" :index="fakerKey">
|
||||
<div>
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ faker['news'][0]!['title'] }}
|
||||
@@ -113,12 +108,7 @@ const sport = ref(false)
|
||||
<Button variant="ghost" type="button" size="sm" class="border border-foreground/20">
|
||||
View Notes
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ms-auto border border-foreground/20"
|
||||
type="button"
|
||||
size="sm"
|
||||
>
|
||||
<Button variant="ghost" class="ms-auto border border-foreground/20" type="button" size="sm">
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import fakers from '@/utils/faker'
|
||||
import _ from 'lodash'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import {
|
||||
resolveNotificationRoute,
|
||||
useNotifications,
|
||||
type NotificationItem,
|
||||
} from '@/modules/notification'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
@@ -11,6 +14,60 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useNotifications()
|
||||
|
||||
function getNotificationIcon(notification: NotificationItem): Icon {
|
||||
if (notification.type === 'user_activation_required') {
|
||||
return 'UserPlus'
|
||||
}
|
||||
|
||||
return 'Bell'
|
||||
}
|
||||
|
||||
function getIconClass(color: string): string {
|
||||
switch (color) {
|
||||
case 'success':
|
||||
return 'bg-success/15 text-success'
|
||||
case 'warning':
|
||||
return 'bg-warning/15 text-warning'
|
||||
case 'info':
|
||||
return 'bg-info/15 text-info'
|
||||
case 'purple':
|
||||
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400'
|
||||
case 'orange':
|
||||
return 'bg-orange-500/15 text-orange-600 dark:text-orange-400'
|
||||
default:
|
||||
return 'bg-primary/15 text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationClick(
|
||||
event: MouseEvent,
|
||||
notification: NotificationItem,
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
await markAsRead(notification.id)
|
||||
|
||||
const route = resolveNotificationRoute(notification)
|
||||
if (route) {
|
||||
await router.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllAsRead(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
await markAllAsRead()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,29 +82,65 @@ const props = defineProps<Props>()
|
||||
:class="`before:shadow-foreground/5 z-50 flex w-96 flex-col gap-2.5 px-6 py-5 before:rounded-2xl before:shadow-xl before:backdrop-blur after:rounded-2xl ${props.boxClass ?? ''}`"
|
||||
>
|
||||
<div class="flex place-content-between items-center">
|
||||
<div class="font-medium">Notifications</div>
|
||||
<a class="text-primary" href="">View More</a>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col gap-2.5">
|
||||
<a
|
||||
v-for="(faker, fakerKey) in _.take(fakers, 5)"
|
||||
:key="fakerKey"
|
||||
class="hover:border-foreground/10 hover:bg-foreground/5 -mx-2 flex items-center gap-3.5 rounded-2xl border border-transparent p-2"
|
||||
href=""
|
||||
<div class="font-medium">
|
||||
Notifikasi
|
||||
<span v-if="unreadCount > 0" class="text-primary ml-1 text-sm">({{ unreadCount }})</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="text-primary text-sm hover:underline"
|
||||
@click="handleMarkAllAsRead"
|
||||
>
|
||||
<AvatarRoot class="size-11 flex-none rounded-full">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="faker['photos'][2]" alt="avatar" />
|
||||
</AvatarRoot>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex place-content-between items-center">
|
||||
<div class="font-medium">{{ faker['users'][0]!['name'] }}</div>
|
||||
<div class="text-xs opacity-70">{{ faker['times'][0] }}</div>
|
||||
Tandakan semua dibaca
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-foreground/70 mt-1 py-6 text-center text-sm">
|
||||
Memuatkan notifikasi...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-danger mt-1 py-4 text-center text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="notifications.length === 0"
|
||||
class="text-foreground/70 mt-1 flex flex-col items-center gap-2 py-8 text-center text-sm"
|
||||
>
|
||||
<Lucide class="size-8 opacity-40" icon="Bell" />
|
||||
<span>Tiada notifikasi buat masa ini.</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-1 flex max-h-96 flex-col gap-1 overflow-y-auto">
|
||||
<a
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="hover:border-foreground/10 hover:bg-foreground/5 -mx-2 flex items-start gap-3.5 rounded-2xl border border-transparent p-2"
|
||||
:class="!notification.is_read && 'bg-primary/5'"
|
||||
href=""
|
||||
@click="handleNotificationClick($event, notification)"
|
||||
>
|
||||
<div
|
||||
class="flex size-11 flex-none items-center justify-center rounded-full"
|
||||
:class="getIconClass(notification.color)"
|
||||
>
|
||||
<Lucide class="size-5" :icon="getNotificationIcon(notification)" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex place-content-between items-start gap-2">
|
||||
<div class="font-medium leading-snug">{{ notification.title }}</div>
|
||||
<div class="shrink-0 text-xs opacity-70">{{ notification.time_ago }}</div>
|
||||
</div>
|
||||
<div class="line-clamp-2 opacity-70">
|
||||
{{ faker['news'][0]!['shortContent'] }}
|
||||
<div class="line-clamp-2 text-sm opacity-70">
|
||||
{{ notification.message }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!notification.is_read"
|
||||
class="bg-primary mt-2 size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -77,75 +77,67 @@ const sport = ref(false)
|
||||
<div class="block h-10 items-center sm:flex">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Official Store</h2>
|
||||
<div class="relative mt-3 sm:ms-auto sm:mt-0">
|
||||
<Lucide
|
||||
class="[--color:var(--color-foreground)]/50 absolute inset-y-0 start-0 z-10 my-auto ms-3"
|
||||
icon="MapPin"
|
||||
/>
|
||||
<Lucide class="[--color:var(--color-foreground)]/50 absolute inset-y-0 start-0 z-10 my-auto ms-3" icon="MapPin" />
|
||||
<Input class="box pl-10 sm:w-56" type="text" placeholder="Filter by city" />
|
||||
</div>
|
||||
</div>
|
||||
<Box class="mt-12 p-5 sm:mt-5">
|
||||
<div>250 Official stores in 21 countries, click the marker to see location details.</div>
|
||||
<Map
|
||||
class="mt-5 w-full h-[327px]"
|
||||
:center="[13.388, 52.517]"
|
||||
:zoom="14"
|
||||
:markers="{
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Cafe Berlin', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.388, 52.517] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Restaurant Alex', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.39, 52.518] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Coffee House', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.385, 52.515] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Pizza Place', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.392, 52.519] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Burger Joint', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.387, 52.516] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Starbucks', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.391, 52.52] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Sushi Bar', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.395, 52.522] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Bakery', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.383, 52.514] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Italian Restaurant', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.398, 52.525] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Tea House', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.38, 52.512] },
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
<Map class="mt-5 w-full h-[327px]" :center="[13.388, 52.517]" :zoom="14" :markers="{
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Cafe Berlin', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.388, 52.517] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Restaurant Alex', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.39, 52.518] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Coffee House', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.385, 52.515] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Pizza Place', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.392, 52.519] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Burger Joint', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.387, 52.516] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Starbucks', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.391, 52.52] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Sushi Bar', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.395, 52.522] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Bakery', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.383, 52.514] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Italian Restaurant', type: 'restaurant' },
|
||||
geometry: { type: 'Point', coordinates: [13.398, 52.525] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'Tea House', type: 'cafe' },
|
||||
geometry: { type: 'Point', coordinates: [13.38, 52.512] },
|
||||
},
|
||||
],
|
||||
}" />
|
||||
</Box>
|
||||
<!-- END: Official Stores -->
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -79,12 +79,9 @@ const sport = ref(false)
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<div
|
||||
class="before:bg-foreground/10 relative mt-5 before:absolute before:ms-5 before:mt-5 before:block before:h-[85%] before:w-px"
|
||||
>
|
||||
class="before:bg-foreground/10 relative mt-5 before:absolute before:ms-5 before:mt-5 before:block before:h-[85%] before:w-px">
|
||||
<div class="relative mb-3 flex items-center">
|
||||
<div
|
||||
class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20"
|
||||
>
|
||||
<div class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20">
|
||||
<AvatarRoot class="size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[9]!['photos'][0]" alt="avatar" />
|
||||
@@ -101,9 +98,7 @@ const sport = ref(false)
|
||||
</Box>
|
||||
</div>
|
||||
<div class="relative mb-3 flex items-center">
|
||||
<div
|
||||
class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20"
|
||||
>
|
||||
<div class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20">
|
||||
<AvatarRoot class="size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[8]!['photos'][0]" alt="avatar" />
|
||||
@@ -137,9 +132,7 @@ const sport = ref(false)
|
||||
</div>
|
||||
<div class="my-4 text-center text-xs opacity-70">12 November</div>
|
||||
<div class="relative mb-3 flex items-center">
|
||||
<div
|
||||
class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20"
|
||||
>
|
||||
<div class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20">
|
||||
<AvatarRoot class="size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[7]!['photos'][0]" alt="avatar" />
|
||||
@@ -162,9 +155,7 @@ const sport = ref(false)
|
||||
</Box>
|
||||
</div>
|
||||
<div class="relative mb-3 flex items-center">
|
||||
<div
|
||||
class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20"
|
||||
>
|
||||
<div class="before:bg-foreground/10 before:absolute before:ms-5 before:mt-5 before:block before:h-px before:w-20">
|
||||
<AvatarRoot class="size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[6]!['photos'][0]" alt="avatar" />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -141,22 +141,22 @@ const sport = ref(false)
|
||||
<div class="mt-5 border-t border-foreground/10 pt-5">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]"
|
||||
></div>
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">UI/UX Workshop</span>
|
||||
<span class="font-medium xl:ms-auto">23th</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]"
|
||||
></div>
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate"> VueJs Frontend Development </span>
|
||||
<span class="font-medium xl:ms-auto">10th</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]"
|
||||
></div>
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">Laravel Rest API</span>
|
||||
<span class="font-medium xl:ms-auto">31th</span>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -78,10 +78,7 @@ const sport = ref(false)
|
||||
<h2 class="me-5 truncate text-lg font-medium">Transactions</h2>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<Box
|
||||
v-for="(faker, fakerKey) in _.take(fakers, 5)"
|
||||
class="mb-3 flex items-center px-5 py-3 before:hidden"
|
||||
>
|
||||
<Box v-for="(faker, fakerKey) in _.take(fakers, 5)" class="mb-3 flex items-center px-5 py-3 before:hidden">
|
||||
<AvatarRoot class="size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="faker['photos'][0]" alt="avatar" />
|
||||
@@ -92,12 +89,10 @@ const sport = ref(false)
|
||||
{{ faker['dates'][0] }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'text-success': faker['trueFalse'][0],
|
||||
'text-danger': !faker['trueFalse'][0],
|
||||
}"
|
||||
>
|
||||
<div :class="{
|
||||
'text-success': faker['trueFalse'][0],
|
||||
'text-danger': !faker['trueFalse'][0],
|
||||
}">
|
||||
{{ faker['trueFalse'][0] ? '+' : '-' }}${{ faker['totals'][0] }}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { datePickerMonthSelect } from "@mykopkb/core/styles/datepicker.styles";
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@/components/ui/native-select";
|
||||
import type { Api } from "@zag-js/date-picker";
|
||||
import { inject } from "vue";
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@/components/ui/native-select";
|
||||
|
||||
import { datePickerYearSelect } from "@mykopkb/core/styles/datepicker.styles";
|
||||
import type { Api } from "@zag-js/date-picker";
|
||||
import { inject } from "vue";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { input } from "@mykopkb/core/styles/input.styles";
|
||||
import { nativeSelect } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select :class="cn(input, nativeSelect, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { nativeSelectOption } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<option :class="cn(nativeSelectOption, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</option>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { NativeSelectOptGroup } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<optgroup :class="cn(NativeSelectOptGroup, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</optgroup>
|
||||
</template>
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as NativeSelect } from "./NativeSelect.vue";
|
||||
export { default as NativeSelectOption } from "./NativeSelectOption.vue";
|
||||
export { default as NativeSelectOptionGroup } from "./NativeSelectOptionGroup.vue";
|
||||
@@ -2,11 +2,13 @@
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { textarea } from "@mykopkb/core/styles/textarea.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea :class="cn(textarea, className)" v-bind="{ ...props }" />
|
||||
<textarea v-model="modelValue" :class="cn(textarea, className)" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
@@ -43,10 +43,17 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import * as select from '@zag-js/select';
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from '@/components/ui/native-select';
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
// Type definitions
|
||||
type ColumnType = 'index' | 'select' | 'action' | 'status' | 'id' | 'date' | 'email' | 'phone' | 'priority' | 'category' | 'registration' | 'model' | 'unit' | 'text';
|
||||
@@ -129,6 +136,22 @@ const resizingColumn = ref<string | null>(null);
|
||||
const startX = ref(0);
|
||||
const perPageOptions = [10, 25, 50, 100, 250, 500, 1000];
|
||||
|
||||
type PerPageSelectOption = { label: string; value: number };
|
||||
|
||||
const perPageSelectOptions: PerPageSelectOption[] = perPageOptions.map((option) => ({
|
||||
label: String(option),
|
||||
value: option,
|
||||
}));
|
||||
|
||||
const perPageCollection = select.collection({
|
||||
items: perPageSelectOptions,
|
||||
itemToValue: (item) => item.label,
|
||||
});
|
||||
|
||||
const perPageInitial = computed(() => [
|
||||
String(itemsPerPageModel.value ?? props.pagination?.per_page ?? props.itemsPerPage ?? 10),
|
||||
]);
|
||||
|
||||
const startWidth = ref(0);
|
||||
|
||||
// Helper function to detect column type from key and title
|
||||
@@ -136,9 +159,10 @@ const detectColumnType = (header: TableHeader): ColumnType => {
|
||||
const key = header.key.toLowerCase();
|
||||
const title = header.title.toLowerCase();
|
||||
|
||||
// Index/Number columns
|
||||
if (key === '#' || key === 'index' || key === 'bil' || key === 'no' ||
|
||||
title.includes('bil') || title.includes('no') || title.includes('#')) {
|
||||
// Index/Number columns (Bil./# row counters — not field labels like "No. Permohonan")
|
||||
if (key === '#' || key === 'index' || key === 'bil' ||
|
||||
title.includes('bil') || title === '#' ||
|
||||
/^no\.?\s*$/i.test(title.trim())) {
|
||||
return 'index';
|
||||
}
|
||||
|
||||
@@ -416,8 +440,9 @@ const headerAlignClass = (align?: TableHeader['align']) => {
|
||||
return 'text-left';
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (event: Event) => {
|
||||
const value = Number((event.target as HTMLSelectElement).value);
|
||||
const setItemsPerPageValue = (details: { value: string[] }) => {
|
||||
const value = Number(details.value[0]);
|
||||
if (Number.isNaN(value)) return;
|
||||
itemsPerPageModel.value = value;
|
||||
emit('update:items-per-page', value);
|
||||
};
|
||||
@@ -791,12 +816,30 @@ watch(sortBy, (newSort) => {
|
||||
class="flex flex-wrap items-center justify-between gap-4 border-t border-foreground/10 bg-foreground/2 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Bilangan rekod per halaman:</span>
|
||||
<NativeSelect class="h-9 w-auto min-w-20 px-2 py-1 text-sm"
|
||||
:value="String(itemsPerPageModel ?? pagination.per_page)" @change="handleItemsPerPageChange">
|
||||
<NativeSelectOption v-for="option in perPageOptions" :key="option" :value="String(option)">
|
||||
{{ option }}
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<SelectRoot
|
||||
:key="perPageInitial[0]"
|
||||
class="w-auto min-w-20"
|
||||
:collection="perPageCollection"
|
||||
:default-value="perPageInitial"
|
||||
@value-change="setItemsPerPageValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger class="h-9 w-auto min-w-20">
|
||||
<SelectValueText />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in perPageCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
NativeSelectOptionGroup,
|
||||
} from "@/components/ui/native-select";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-20">
|
||||
<div class="grid sm:grid-cols-2">
|
||||
<div
|
||||
class="justify-center items-center flex gap-2 border-b border-e border-foreground/10 p-5 flex-wrap"
|
||||
>
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress"
|
||||
>In Progress</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div
|
||||
class="justify-center items-center flex gap-2 border-b border-e border-foreground/10 p-5 flex-wrap"
|
||||
>
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select department</NativeSelectOption>
|
||||
<NativeSelectOptionGroup label="Engineering">
|
||||
<NativeSelectOption value="frontend">Frontend</NativeSelectOption>
|
||||
<NativeSelectOption value="backend">Backend</NativeSelectOption>
|
||||
<NativeSelectOption value="devops">DevOps</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Sales">
|
||||
<NativeSelectOption value="sales-rep">Sales Rep</NativeSelectOption>
|
||||
<NativeSelectOption value="account-manager">
|
||||
Account Manager
|
||||
</NativeSelectOption>
|
||||
<NativeSelectOption value="sales-director">
|
||||
Sales Director
|
||||
</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Operations">
|
||||
<NativeSelectOption value="support">
|
||||
Customer Support
|
||||
</NativeSelectOption>
|
||||
<NativeSelectOption value="product-manager">
|
||||
Product Manager
|
||||
</NativeSelectOption>
|
||||
<NativeSelectOption value="ops-manager">
|
||||
Operations Manager
|
||||
</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div
|
||||
class="justify-center items-center flex gap-2 border-b border-e border-foreground/10 p-5 flex-wrap"
|
||||
>
|
||||
<NativeSelect class="w-56" disabled>
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress"
|
||||
>In Progress</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -78,10 +78,7 @@ const sport = ref(false)
|
||||
<h2 class="me-5 truncate text-lg font-medium">Weekly Best Sellers</h2>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<Box
|
||||
v-for="(faker, fakerKey) in _.take(fakers, 4)"
|
||||
class="mb-3 flex items-center px-4 py-4 before:hidden"
|
||||
>
|
||||
<Box v-for="(faker, fakerKey) in _.take(fakers, 4)" class="mb-3 flex items-center px-4 py-4 before:hidden">
|
||||
<AvatarRoot class="rounded-xl size-11">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="faker['photos'][0]" alt="avatar" />
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
CarouselRoot,
|
||||
@@ -147,12 +146,7 @@ const sport = ref(false)
|
||||
</Table>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot
|
||||
:count="5000"
|
||||
:pageSize="10"
|
||||
:siblingCount="2"
|
||||
class="w-full sm:me-auto sm:w-auto"
|
||||
>
|
||||
<PaginationRoot :count="5000" :pageSize="10" :siblingCount="2" class="w-full sm:me-auto sm:w-auto">
|
||||
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
|
||||
<PaginationContext v-slot="{ pagination }">
|
||||
<template v-for="(page, index) in pagination?.pages" :key="index">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import type { MenuList } from '@/core/utils/filterMenuByPermission'
|
||||
import { filterMenuByPermission } from '@/core/utils/filterMenuByPermission'
|
||||
import { usePermissions } from './usePermissions'
|
||||
|
||||
export function useFilteredMenu(menu: MenuList): ComputedRef<MenuList> {
|
||||
const { activeRolePermissions, hasPermission } = usePermissions()
|
||||
|
||||
return computed(() => {
|
||||
void activeRolePermissions.value
|
||||
|
||||
return filterMenuByPermission(menu, hasPermission)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
getActiveRolePermissions,
|
||||
hasActiveRolePermission,
|
||||
hasAnyActiveRolePermission,
|
||||
} from '@/core/utils/activeRolePermission'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AuthPermission } from '@/modules/auth/types/auth.types'
|
||||
|
||||
const DEVELOPER_ROLE = 'DEVELOPER'
|
||||
|
||||
export function usePermissions() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activeRolePermissions = computed<AuthPermission[]>(() =>
|
||||
getActiveRolePermissions(authStore.user, authStore.activeRole),
|
||||
)
|
||||
|
||||
const isDeveloper = computed(
|
||||
() => authStore.activeRole?.name === DEVELOPER_ROLE,
|
||||
)
|
||||
|
||||
function hasPermission(permission: string): boolean {
|
||||
return hasActiveRolePermission(
|
||||
authStore.user,
|
||||
authStore.activeRole,
|
||||
permission,
|
||||
)
|
||||
}
|
||||
|
||||
function hasAnyPermission(permissions: string[]): boolean {
|
||||
return hasAnyActiveRolePermission(
|
||||
authStore.user,
|
||||
authStore.activeRole,
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
activeRolePermissions,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
isDeveloper,
|
||||
}
|
||||
}
|
||||
@@ -59,28 +59,9 @@ export function useRoleSwitcher() {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRoleLabel = availableRoles.value.find((r) => r.id === nextRoleId)?.name
|
||||
const currentRoleLabel = authStore.activeRole?.name
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Tukar peranan?',
|
||||
text: `Anda akan bertukar daripada "${currentRoleLabel}" kepada "${nextRoleLabel}".`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya, bertukar',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true,
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
roleComboValue.value = authStore.activeRole?.id ? [authStore.activeRole.id] : ['']
|
||||
selectedRoleId.value = authStore.activeRole?.id ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
roleComboValue.value = [nextRoleId]
|
||||
selectedRoleId.value = nextRoleId
|
||||
await onSwitchRole()
|
||||
await onSwitchRole(nextRoleId)
|
||||
}
|
||||
|
||||
const onRoleInputValueChange = ({ inputValue }: { inputValue: string }) => {
|
||||
@@ -94,12 +75,12 @@ export function useRoleSwitcher() {
|
||||
roleOptions.value = filtered.length > 0 ? filtered : all
|
||||
}
|
||||
|
||||
const onSwitchRole = async () => {
|
||||
if (!selectedRoleId.value || selectedRoleId.value === authStore.activeRole?.id) return
|
||||
const onSwitchRole = async (roleId: string) => {
|
||||
if (!roleId || roleId === authStore.activeRole?.id) return
|
||||
|
||||
switchingRole.value = true
|
||||
try {
|
||||
const res = await authStore.switchRole(selectedRoleId.value)
|
||||
const res = await authStore.switchRole(roleId)
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
|
||||
@@ -6,5 +6,6 @@ export interface Menu {
|
||||
route_name?: string
|
||||
params?: Record<string, unknown>
|
||||
badge?: number
|
||||
permission?: string
|
||||
sub_menu?: Menu[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AuthPermission, AuthRole, AuthUser } from '@/modules/auth/types/auth.types'
|
||||
|
||||
const DEVELOPER_ROLE = 'DEVELOPER'
|
||||
|
||||
export function getActiveRolePermissions(
|
||||
user: AuthUser | null | undefined,
|
||||
activeRole: AuthRole | null | undefined,
|
||||
): AuthPermission[] {
|
||||
if (!user?.roles?.length || !activeRole) {
|
||||
return []
|
||||
}
|
||||
|
||||
return user.roles.find((role) => role.id === activeRole.id)?.permissions ?? []
|
||||
}
|
||||
|
||||
export function hasActiveRolePermission(
|
||||
user: AuthUser | null | undefined,
|
||||
activeRole: AuthRole | null | undefined,
|
||||
permission: string,
|
||||
): boolean {
|
||||
if (activeRole?.name === DEVELOPER_ROLE) {
|
||||
return true
|
||||
}
|
||||
|
||||
return getActiveRolePermissions(user, activeRole).some(
|
||||
(item) => item.name === permission,
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAnyActiveRolePermission(
|
||||
user: AuthUser | null | undefined,
|
||||
activeRole: AuthRole | null | undefined,
|
||||
permissions: string[],
|
||||
): boolean {
|
||||
return permissions.some((permission) =>
|
||||
hasActiveRolePermission(user, activeRole, permission),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export type MenuList = (string | Menu)[]
|
||||
|
||||
function isMenuVisible(menu: Menu, hasPermission: (permission: string) => boolean): boolean {
|
||||
if (menu.sub_menu?.length) {
|
||||
return menu.sub_menu.some((item) => isMenuVisible(item, hasPermission))
|
||||
}
|
||||
|
||||
if (!menu.permission) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hasPermission(menu.permission)
|
||||
}
|
||||
|
||||
function filterMenuItem(menu: Menu, hasPermission: (permission: string) => boolean): Menu | null {
|
||||
if (menu.sub_menu?.length) {
|
||||
const subMenu = menu.sub_menu
|
||||
.map((item) => filterMenuItem(item, hasPermission))
|
||||
.filter((item): item is Menu => item !== null)
|
||||
|
||||
if (!subMenu.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...menu, sub_menu: subMenu }
|
||||
}
|
||||
|
||||
return isMenuVisible(menu, hasPermission) ? menu : null
|
||||
}
|
||||
|
||||
function sectionHasVisibleItems(
|
||||
menu: MenuList,
|
||||
startIndex: number,
|
||||
hasPermission: (permission: string) => boolean,
|
||||
): boolean {
|
||||
for (let index = startIndex + 1; index < menu.length; index += 1) {
|
||||
const entry = menu[index]
|
||||
|
||||
if (typeof entry === 'string') {
|
||||
break
|
||||
}
|
||||
|
||||
if (entry && isMenuVisible(entry, hasPermission)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function filterMenuByPermission(
|
||||
menu: MenuList,
|
||||
hasPermission: (permission: string) => boolean,
|
||||
): MenuList {
|
||||
const filtered: MenuList = []
|
||||
|
||||
menu.forEach((entry, index) => {
|
||||
if (typeof entry === 'string') {
|
||||
if (sectionHasVisibleItems(menu, index, hasPermission)) {
|
||||
filtered.push(entry)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const visibleItem = filterMenuItem(entry, hasPermission)
|
||||
if (visibleItem) {
|
||||
filtered.push(visibleItem)
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
@@ -5,6 +5,18 @@ type ApiErrorBody = {
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export function getApiValidationErrors(error: unknown): Record<string, string[]> | null {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as ApiErrorBody | undefined
|
||||
|
||||
if (data?.errors && Object.keys(data.errors).length > 0) {
|
||||
return data.errors
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(
|
||||
error: unknown,
|
||||
fallback = 'Something went wrong. Please try again.',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export function getRouteRequiredPermissions(
|
||||
route: RouteLocationNormalized,
|
||||
): string[] {
|
||||
for (const record of [...route.matched].reverse()) {
|
||||
if (typeof record.meta.permission === 'string') {
|
||||
return [record.meta.permission]
|
||||
}
|
||||
|
||||
if (Array.isArray(record.meta.permissions) && record.meta.permissions.length) {
|
||||
return record.meta.permissions
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -126,361 +126,4 @@ import { Box } from "@/components/ui/box";
|
||||
</template>
|
||||
</Preview>
|
||||
</div>
|
||||
<div id="installation">
|
||||
<SectionTitle>Installation</SectionTitle>
|
||||
<SectionContent>Install the following dependencies:</SectionContent>
|
||||
<InstallPackage>add @zag-js/vue @zag-js/accordion</InstallPackage>
|
||||
<SectionContent>
|
||||
Copy and paste the following code into your project.
|
||||
</SectionContent>
|
||||
<PreviewCode title="components/ui/accordion/AccordionContent.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { accordionContent } from "@mykopkb/core/styles/accordion.styles";
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { inject } from "vue";
|
||||
import { Slot } from "@/components/ui/slot";
|
||||
import type { Api, ItemProps } from "@zag-js/accordion";
|
||||
|
||||
const {
|
||||
class: className,
|
||||
asChild = false,
|
||||
...props
|
||||
} = defineProps<{
|
||||
class?: string;
|
||||
asChild?: boolean;
|
||||
}>();
|
||||
|
||||
const api = inject<Api>("accordionApi");
|
||||
const item = inject<ItemProps>("accordionItem");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot :class="cn([className, accordionContent])"
|
||||
v-bind="{ ...props, ...$attrs, ...api?.getItemContentProps(item!) }">
|
||||
<slot v-if="asChild" />
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</Slot>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/accordion/AccordionItem.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { accordionItemVariants } from "@mykopkb/core/styles/accordion.styles";
|
||||
import {
|
||||
boxVariants,
|
||||
type BoxVariants,
|
||||
} from "@mykopkb/core/styles/box.styles";
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { inject, provide } from "vue";
|
||||
import { Slot } from "@/components/ui/slot";
|
||||
import type { Api, ItemProps } from "@zag-js/accordion";
|
||||
|
||||
const {
|
||||
raised,
|
||||
class: className,
|
||||
asChild = false,
|
||||
...props
|
||||
} = defineProps<
|
||||
BoxVariants & ItemProps & { class?: string; asChild?: boolean }
|
||||
>();
|
||||
|
||||
const variant = inject<"default" | "boxed">("accordionVariant", "default");
|
||||
const api = inject<Api>("accordionApi");
|
||||
|
||||
provide("accordionItem", props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot :class="
|
||||
cn([
|
||||
className,
|
||||
variant == 'boxed' ? boxVariants({ raised, className }) : '',
|
||||
accordionItemVariants({ variant, className }),
|
||||
])
|
||||
" v-bind="{ ...props, ...$attrs, ...api?.getItemProps(props) }">
|
||||
<slot v-if="asChild" />
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</Slot>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/accordion/AccordionRoot.vue">
|
||||
{{`
|
||||
<script lang="ts" setup>
|
||||
import { accordionRootVariants } from "@mykopkb/core/styles/accordion.styles";
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { provide, computed } from "vue";
|
||||
import * as accordion from "@zag-js/accordion";
|
||||
import type { Props } from "@zag-js/accordion";
|
||||
import { useMachine, normalizeProps } from "@zag-js/vue";
|
||||
import { Slot } from "@/components/ui/slot";
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
collapsible = true,
|
||||
...props
|
||||
} = defineProps<
|
||||
Partial<Props> & {
|
||||
class?: string;
|
||||
variant?: "default" | "boxed";
|
||||
asChild?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const service = useMachine(accordion.machine, {
|
||||
collapsible,
|
||||
...props,
|
||||
id: crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const api = computed(() => accordion.connect(service, normalizeProps));
|
||||
|
||||
provide("accordionVariant", variant);
|
||||
provide("accordionApi", api);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot :class="cn([className, accordionRootVariants({ variant, className })])"
|
||||
v-bind="{ ...props, ...$attrs, ...api.getRootProps() }">
|
||||
<slot v-if="asChild" />
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</Slot>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/accordion/AccordionTrigger.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { ChevronDownIcon } from "@lucide/vue";
|
||||
import {
|
||||
accordionTrigger,
|
||||
accordionItemIndicator,
|
||||
} from "@mykopkb/core/styles/accordion.styles";
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { inject } from "vue";
|
||||
import { Slot } from "@/components/ui/slot";
|
||||
import type { Api, ItemProps } from "@zag-js/accordion";
|
||||
|
||||
const {
|
||||
class: className,
|
||||
asChild = false,
|
||||
...props
|
||||
} = defineProps<{
|
||||
class?: string;
|
||||
asChild?: boolean;
|
||||
}>();
|
||||
|
||||
const api = inject<Api>("accordionApi");
|
||||
const item = inject<ItemProps>("accordionItem");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot :class="cn([className, accordionTrigger])"
|
||||
v-bind="{ ...props, ...$attrs, ...api?.getItemTriggerProps(item!) }">
|
||||
<slot v-if="asChild" />
|
||||
<button v-else>
|
||||
<slot />
|
||||
<div v-bind="api?.getItemIndicatorProps(item!)" :class="cn(accordionItemIndicator)">
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
</Slot>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/accordion/index.ts">
|
||||
{{ `
|
||||
export { default as AccordionRoot } from "./AccordionRoot.vue";
|
||||
export { default as AccordionItem } from "./AccordionItem.vue";
|
||||
export { default as AccordionTrigger } from "./AccordionTrigger.vue";
|
||||
export { default as AccordionContent } from "./AccordionContent.vue";
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<SectionContent>
|
||||
Update the import paths to match your project setup.
|
||||
</SectionContent>
|
||||
</div>
|
||||
<div id="usage">
|
||||
<SectionTitle>Usage</SectionTitle>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
import {
|
||||
AccordionRoot,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Box } from "@/components/ui/box";
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<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>
|
||||
</div>
|
||||
<div id="variants">
|
||||
<SectionTitle>Variants</SectionTitle>
|
||||
<SectionContent>A collection of components you can use.</SectionContent>
|
||||
<Preview>
|
||||
<template #preview>
|
||||
<AccordionRoot class="w-full" variant="boxed" :default-value="['product-information']">
|
||||
<AccordionItem raised="single" 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 raised="single" 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 raised="single" 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>
|
||||
</template>
|
||||
<template #code>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
<AccordionRoot class="w-full" variant="boxed" :default-value="['product-information']">
|
||||
<AccordionItem raised="single" 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 raised="single" 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 raised="single" 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>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
</template>
|
||||
</Preview>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import axios from "axios";
|
||||
import {
|
||||
SectionTitle,
|
||||
SectionContent,
|
||||
InstallPackage,
|
||||
} from "@/components/docs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import DataTable from "@/components/ui/usage/DataTable.vue";
|
||||
import type { SortConfig, TableHeader } from "@/components/ui/usage/DataTable.vue";
|
||||
import { api } from "@/core/services/api";
|
||||
import { useApiPagination } from "@/composables/useApiPagination";
|
||||
import type { PaginatedApiResponse } from "@/core/types/api";
|
||||
|
||||
type UserRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
guard_name: string;
|
||||
};
|
||||
|
||||
type UserListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
ic_number: string;
|
||||
position: string;
|
||||
phone_number: string;
|
||||
image_url: string | null;
|
||||
status: string;
|
||||
roles: UserRole[];
|
||||
};
|
||||
|
||||
const users = ref<UserListItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const search = ref("");
|
||||
const statusFilter = ref("");
|
||||
const sortBy = ref<SortConfig[]>([{ key: "name", order: "asc" }]);
|
||||
const page = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 });
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: "Name", key: "name", sortable: true },
|
||||
{ title: "Email", key: "email", sortable: true },
|
||||
{ title: "Position", key: "position", sortable: true },
|
||||
{
|
||||
title: "Role",
|
||||
key: "roles",
|
||||
sortable: false,
|
||||
exportValue: (item) =>
|
||||
item.roles?.map((role: UserRole) => role.name).join(", ") || "-",
|
||||
},
|
||||
{ title: "Status", key: "status", sortable: true },
|
||||
];
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
if (status === "active") return "success";
|
||||
if (status === "inactive") return "danger";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
async function fetchUsers(requestPage = page.value) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0];
|
||||
const { data } = await api.get<PaginatedApiResponse<UserListItem>>(
|
||||
"/v1/users",
|
||||
{
|
||||
params: {
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? "name",
|
||||
sort_order: activeSort?.order ?? "asc",
|
||||
...(search.value.trim() ? { search: search.value.trim() } : {}),
|
||||
...(statusFilter.value.trim()
|
||||
? { status: statusFilter.value.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? "Failed to load users");
|
||||
}
|
||||
|
||||
users.value = data.data;
|
||||
applyPagination(data.pagination);
|
||||
page.value = data.pagination.current_page;
|
||||
} catch (err) {
|
||||
users.value = [];
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
const message = (err.response?.data as { message?: string } | undefined)
|
||||
?.message;
|
||||
error.value = message ?? err.message;
|
||||
} else if (err instanceof Error) {
|
||||
error.value = err.message;
|
||||
} else {
|
||||
error.value = "Failed to load users";
|
||||
}
|
||||
|
||||
applyPagination({
|
||||
current_page: 1,
|
||||
per_page: itemsPerPage.value,
|
||||
total: 0,
|
||||
last_page: 1,
|
||||
from: null,
|
||||
to: null,
|
||||
has_more_pages: false,
|
||||
});
|
||||
page.value = 1;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value;
|
||||
fetchUsers(1);
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchUsers(1);
|
||||
}, 400);
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch();
|
||||
});
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchUsers(1);
|
||||
});
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchUsers(nextPage);
|
||||
}
|
||||
});
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchUsers(1);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="preview" class="-mt-20 w-full space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input v-model="search" placeholder="Search name, email, IC, phone, role..." class="max-w-96" />
|
||||
<Input v-model="statusFilter" placeholder="Filter status (e.g. active)" class="max-w-56" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
|
||||
|
||||
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate">
|
||||
<template #item.name="{ item }">
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.email="{ item }">
|
||||
<span class="lowercase">{{ item.email }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.roles="{ item }">
|
||||
{{item.roles?.map((role: UserRole) => role.name).join(", ") || "-"}}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize">
|
||||
{{ item.status }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div id="installation">
|
||||
<SectionTitle>Installation</SectionTitle>
|
||||
<SectionContent>Install the following dependencies:</SectionContent>
|
||||
<InstallPackage class="mb-0">
|
||||
add xlsx jspdf jspdf-autotable file-saver
|
||||
</InstallPackage>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,7 +100,7 @@ import {
|
||||
<DatePickerTableCell v-for="(month, id) in months" :key="id" :value="month.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
month.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
@@ -124,7 +124,7 @@ import {
|
||||
<DatePickerTableCell v-for="(year, id) in years" :key="id" :value="year.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
year.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
@@ -522,10 +522,7 @@ const api = inject<Api>("datepickerApi");
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@/components/ui/native-select";
|
||||
|
||||
import { datePickerYearSelect } from "@mykopkb/core/styles/datepicker.styles";
|
||||
import type { Api } from "@zag-js/date-picker";
|
||||
import { inject } from "vue";
|
||||
@@ -557,10 +554,7 @@ const api = inject<Api>("datepickerApi");
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { datePickerMonthSelect } from "@mykopkb/core/styles/datepicker.styles";
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@/components/ui/native-select";
|
||||
|
||||
import type { Api } from "@zag-js/date-picker";
|
||||
import { inject } from "vue";
|
||||
|
||||
@@ -1312,7 +1306,7 @@ const api = inject<Api>("datepickerApi");
|
||||
<DatePickerTableCell v-for="(month, id) in months" :key="id" :value="month.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
month.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
@@ -1336,7 +1330,7 @@ const api = inject<Api>("datepickerApi");
|
||||
<DatePickerTableCell v-for="(year, id) in years" :key="id" :value="year.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
year.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
@@ -1649,7 +1643,7 @@ const api = inject<Api>("datepickerApi");
|
||||
<DatePickerTableCell v-for="(month, id) in months" :key="id" :value="month.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
month.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
@@ -1673,7 +1667,7 @@ const api = inject<Api>("datepickerApi");
|
||||
<DatePickerTableCell v-for="(year, id) in years" :key="id" :value="year.value">
|
||||
<DatePickerTableCellTrigger>{{
|
||||
year.label
|
||||
}}</DatePickerTableCellTrigger>
|
||||
}}</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
NativeSelectOptionGroup,
|
||||
} from "@/components/ui/native-select";
|
||||
import {
|
||||
Preview,
|
||||
SectionTitle,
|
||||
SectionContent,
|
||||
PreviewCode,
|
||||
} from "@/components/docs";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="preview" class="-mt-20">
|
||||
<Preview>
|
||||
<template #preview>
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</template>
|
||||
<template #code>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
` }}
|
||||
</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/native-select/NativeSelect.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { input } from "@mykopkb/core/styles/input.styles";
|
||||
import { nativeSelect } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select :class="cn(input, nativeSelect, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/native-select/NativeSelectOption.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { nativeSelectOption } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<option :class="cn(nativeSelectOption, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</option>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/native-select/NativeSelectOptionGroup.vue">
|
||||
{{ `
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@mykopkb/core/utils/cn";
|
||||
import { NativeSelectOptGroup } from "@mykopkb/core/styles/native-select.styles";
|
||||
|
||||
const { class: className, ...props } = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<optgroup :class="cn(NativeSelectOptGroup, className)" v-bind="{ ...props, ...$attrs }">
|
||||
<slot />
|
||||
</optgroup>
|
||||
</template>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode title="components/ui/native-select/index.ts">
|
||||
{{ `
|
||||
export { default as NativeSelect } from "./NativeSelect.vue";
|
||||
export { default as NativeSelectOption } from "./NativeSelectOption.vue";
|
||||
export { default as NativeSelectOptionGroup } from "./NativeSelectOptionGroup.vue";
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<SectionContent>
|
||||
Update the import paths to match your project setup.
|
||||
</SectionContent>
|
||||
</div>
|
||||
<div id="usage">
|
||||
<SectionTitle>Usage</SectionTitle>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
NativeSelectOptionGroup,
|
||||
} from "@/components/ui/native-select";
|
||||
` }}
|
||||
</PreviewCode>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
</div>
|
||||
<div id="variants">
|
||||
<SectionTitle>Variants</SectionTitle>
|
||||
<SectionContent>A collection of components you can use.</SectionContent>
|
||||
<Preview>
|
||||
<template #preview>
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select department</NativeSelectOption>
|
||||
<NativeSelectOptionGroup label="Engineering">
|
||||
<NativeSelectOption value="frontend">Frontend</NativeSelectOption>
|
||||
<NativeSelectOption value="backend">Backend</NativeSelectOption>
|
||||
<NativeSelectOption value="devops">DevOps</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Sales">
|
||||
<NativeSelectOption value="sales-rep">Sales Rep</NativeSelectOption>
|
||||
<NativeSelectOption value="account-manager">Account Manager</NativeSelectOption>
|
||||
<NativeSelectOption value="sales-director">Sales Director</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Operations">
|
||||
<NativeSelectOption value="support">Customer Support</NativeSelectOption>
|
||||
<NativeSelectOption value="product-manager">Product Manager</NativeSelectOption>
|
||||
<NativeSelectOption value="ops-manager">Operations Manager</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
</NativeSelect>
|
||||
</template>
|
||||
<template #code>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
<NativeSelect class="w-56">
|
||||
<NativeSelectOption value="">Select department</NativeSelectOption>
|
||||
<NativeSelectOptionGroup label="Engineering">
|
||||
<NativeSelectOption value="frontend">Frontend</NativeSelectOption>
|
||||
<NativeSelectOption value="backend">Backend</NativeSelectOption>
|
||||
<NativeSelectOption value="devops">DevOps</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Sales">
|
||||
<NativeSelectOption value="sales-rep">Sales Rep</NativeSelectOption>
|
||||
<NativeSelectOption value="account-manager">Account Manager</NativeSelectOption>
|
||||
<NativeSelectOption value="sales-director">Sales Director</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
<NativeSelectOptionGroup label="Operations">
|
||||
<NativeSelectOption value="support">Customer Support</NativeSelectOption>
|
||||
<NativeSelectOption value="product-manager">Product Manager</NativeSelectOption>
|
||||
<NativeSelectOption value="ops-manager">Operations Manager</NativeSelectOption>
|
||||
</NativeSelectOptionGroup>
|
||||
</NativeSelect>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
</template>
|
||||
</Preview>
|
||||
<Preview>
|
||||
<template #preview>
|
||||
<NativeSelect class="w-56" disabled>
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</template>
|
||||
<template #code>
|
||||
<PreviewCode>
|
||||
{{ `
|
||||
<NativeSelect class="w-56" disabled>
|
||||
<NativeSelectOption value="">Select status</NativeSelectOption>
|
||||
<NativeSelectOption value="todo">Todo</NativeSelectOption>
|
||||
<NativeSelectOption value="in-progress">In Progress</NativeSelectOption>
|
||||
<NativeSelectOption value="done">Done</NativeSelectOption>
|
||||
<NativeSelectOption value="cancelled">Cancelled</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
` }}
|
||||
</PreviewCode>
|
||||
</template>
|
||||
</Preview>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,20 @@
|
||||
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'
|
||||
import { activityMenu } from '@/modules/activity'
|
||||
|
||||
export type { Menu }
|
||||
|
||||
const mainMenu: (string | Menu)[] = [
|
||||
'Umum',
|
||||
...activityMenu,
|
||||
'Teknologi Maklumat',
|
||||
...roleMenu,
|
||||
'Pentadbiran',
|
||||
...membershipApplicationMenu,
|
||||
...userMenu,
|
||||
'GENERAL REPORTS',
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
@@ -88,8 +98,6 @@ const mainMenu: (string | Menu)[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
'Admin',
|
||||
...userMenu,
|
||||
'APPS',
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
@@ -411,11 +419,6 @@ const mainMenu: (string | Menu)[] = [
|
||||
route_name: 'table',
|
||||
title: 'Table',
|
||||
},
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
route_name: 'data-table',
|
||||
title: 'Data Table',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogCloseTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createActivityReport, deleteActivityReport, updateActivityReport } from '../services/activity.service'
|
||||
import type { ActivityReport } from '../types/activity.types'
|
||||
|
||||
type ReportModalMode = 'create' | 'edit'
|
||||
|
||||
const props = defineProps<{
|
||||
activityId: string
|
||||
reports: ActivityReport[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreateReport = computed(() => hasPermission('tambah laporan aktiviti'))
|
||||
const canEditReport = computed(() => hasPermission('kemaskini laporan aktiviti'))
|
||||
const canDeleteReport = computed(() => hasPermission('hapus laporan aktiviti'))
|
||||
|
||||
const reportModalOpen = ref(false)
|
||||
const reportModalMode = ref<ReportModalMode>('create')
|
||||
const editingReportId = ref<string | null>(null)
|
||||
const reportText = ref('')
|
||||
const reportFieldError = ref<string | null>(null)
|
||||
const savingReport = ref(false)
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const reportToDelete = ref<ActivityReport | null>(null)
|
||||
const deletingReport = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const isEditMode = computed(() => reportModalMode.value === 'edit')
|
||||
|
||||
const reportModalTitle = computed(() =>
|
||||
isEditMode.value ? 'Kemaskini Laporan Aktiviti' : 'Tambah Laporan Aktiviti',
|
||||
)
|
||||
|
||||
const reportModalDescription = computed(() =>
|
||||
isEditMode.value
|
||||
? 'Kemaskini kandungan laporan aktiviti ini.'
|
||||
: 'Rekod laporan untuk aktiviti ini. Laporan akan dikaitkan dengan akaun anda.',
|
||||
)
|
||||
|
||||
const reportSubmitLabel = computed(() => {
|
||||
if (savingReport.value) return 'Menyimpan...'
|
||||
return isEditMode.value ? 'Simpan Perubahan' : 'Simpan Laporan'
|
||||
})
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function resetReportForm() {
|
||||
reportText.value = ''
|
||||
reportFieldError.value = null
|
||||
editingReportId.value = null
|
||||
reportModalMode.value = 'create'
|
||||
}
|
||||
|
||||
function openCreateReportModal() {
|
||||
resetReportForm()
|
||||
reportModalMode.value = 'create'
|
||||
reportModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditReportModal(report: ActivityReport) {
|
||||
reportFieldError.value = null
|
||||
reportModalMode.value = 'edit'
|
||||
editingReportId.value = report.id
|
||||
reportText.value = report.report_text
|
||||
reportModalOpen.value = true
|
||||
}
|
||||
|
||||
function handleReportModalOpenChange(details: { open: boolean }) {
|
||||
if (savingReport.value && !details.open) {
|
||||
reportModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
reportModalOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
resetReportForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReport() {
|
||||
if (savingReport.value) return
|
||||
|
||||
const trimmedText = reportText.value.trim()
|
||||
if (!trimmedText) {
|
||||
reportFieldError.value = 'Laporan diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
savingReport.value = true
|
||||
reportFieldError.value = null
|
||||
|
||||
try {
|
||||
if (isEditMode.value && editingReportId.value) {
|
||||
await updateActivityReport(editingReportId.value, {
|
||||
activity_id: props.activityId,
|
||||
report_text: trimmedText,
|
||||
})
|
||||
} else {
|
||||
await createActivityReport({
|
||||
activity_id: props.activityId,
|
||||
report_text: trimmedText,
|
||||
})
|
||||
}
|
||||
|
||||
reportModalOpen.value = false
|
||||
resetReportForm()
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.report_text?.[0]) {
|
||||
reportFieldError.value = validationErrors.report_text[0]
|
||||
} else {
|
||||
reportFieldError.value = getApiErrorMessage(
|
||||
err,
|
||||
isEditMode.value
|
||||
? 'Gagal mengemaskini laporan aktiviti.'
|
||||
: 'Gagal menambah laporan aktiviti.',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
savingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(report: ActivityReport) {
|
||||
reportToDelete.value = report
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteConfirmationOpenChange(details: { open: boolean }) {
|
||||
if (deletingReport.value && !details.open) {
|
||||
deleteConfirmationOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
deleteConfirmationOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
reportToDelete.value = null
|
||||
deleteError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteReport() {
|
||||
if (!reportToDelete.value || deletingReport.value) return
|
||||
|
||||
deletingReport.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteActivityReport(reportToDelete.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
reportToDelete.value = null
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal memadam laporan aktiviti.')
|
||||
} finally {
|
||||
deletingReport.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Laporan Aktiviti</div>
|
||||
<p class="mt-1 text-sm opacity-70">{{ reports.length }} laporan</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canCreateReport"
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
@click="openCreateReportModal"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Plus" />
|
||||
Tambah Laporan
|
||||
</Button>
|
||||
<Lucide class="size-5 opacity-50" icon="FileText" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!reports.length" class="mt-5 opacity-70">Tiada laporan aktiviti.</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-4">
|
||||
<div
|
||||
v-for="report in reports"
|
||||
:key="report.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-foreground/10 pb-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ report.prepared_by_user?.name ?? 'Pengguna' }}
|
||||
</div>
|
||||
<div v-if="report.prepared_by_user?.email" class="mt-0.5 text-xs opacity-70">
|
||||
{{ report.prepared_by_user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xs opacity-70">
|
||||
{{ formatDateTime(report.created_at) }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="canEditReport"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
title="Kemaskini laporan"
|
||||
@click="openEditReportModal(report)"
|
||||
>
|
||||
<Lucide class="size-4 opacity-70" icon="Pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteReport"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
title="Padam laporan"
|
||||
@click="openDeleteConfirmation(report)"
|
||||
>
|
||||
<Lucide class="size-4 text-danger opacity-80" icon="Trash2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 whitespace-pre-line text-sm leading-relaxed">
|
||||
{{ report.report_text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<DialogRoot :open="reportModalOpen" @openChange="handleReportModalOpenChange">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogTitle>{{ reportModalTitle }}</DialogTitle>
|
||||
<DialogDescription>{{ reportModalDescription }}</DialogDescription>
|
||||
|
||||
<form class="mt-4 space-y-4" @submit.prevent="handleSubmitReport">
|
||||
<Field>
|
||||
<FieldLabel for="activity-report-text">Laporan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-report-text"
|
||||
v-model="reportText"
|
||||
rows="6"
|
||||
placeholder="Tulis laporan aktiviti..."
|
||||
:disabled="savingReport"
|
||||
required
|
||||
/>
|
||||
<FieldError v-if="reportFieldError">{{ reportFieldError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<DialogCloseTrigger look="outline" variant="secondary" :disabled="savingReport">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button type="submit" variant="primary" :disabled="savingReport">
|
||||
{{ reportSubmitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="handleDeleteConfirmationOpenChange">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Padam Laporan Aktiviti?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu memadam laporan ini?
|
||||
<br />
|
||||
Proses ini tidak boleh dibatalkan.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deletingReport">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deletingReport"
|
||||
@click="confirmDeleteReport"
|
||||
>
|
||||
{{ deletingReport ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogCloseTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createActivityType, listActivityTypes } from '../services/activity.service'
|
||||
import type { ActivityType } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
autoSelectFirst?: boolean
|
||||
}>(),
|
||||
{
|
||||
autoSelectFirst: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreateType = computed(() => hasPermission('tambah jenis aktiviti'))
|
||||
|
||||
const loading = ref(false)
|
||||
const activityTypes = ref<ActivityType[]>([])
|
||||
const selectKey = ref(0)
|
||||
const selectInitial = ref<string[]>([])
|
||||
|
||||
const typeModalOpen = ref(false)
|
||||
const typeName = ref('')
|
||||
const typeCode = ref('')
|
||||
const typeNameError = ref<string | null>(null)
|
||||
const typeCodeError = ref<string | null>(null)
|
||||
const savingType = ref(false)
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
function formatTypeLabel(type: ActivityType): string {
|
||||
return type.code ? `${type.name} (${type.code})` : type.name
|
||||
}
|
||||
|
||||
const activityTypeOptions = computed<SelectOption[]>(() =>
|
||||
activityTypes.value.map((type) => ({
|
||||
label: formatTypeLabel(type),
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const activityTypeCollection = computed(() =>
|
||||
createSelectCollection(activityTypeOptions.value),
|
||||
)
|
||||
|
||||
function syncSelectInitial() {
|
||||
if (!model.value) {
|
||||
selectInitial.value = []
|
||||
return
|
||||
}
|
||||
|
||||
selectInitial.value = valueToLabel(activityTypeOptions.value, model.value)
|
||||
selectKey.value += 1
|
||||
}
|
||||
|
||||
function setActivityTypeValue(details: { value: string[] }) {
|
||||
model.value = labelToValue(activityTypeOptions.value, details.value[0])
|
||||
}
|
||||
|
||||
function resetTypeForm() {
|
||||
typeName.value = ''
|
||||
typeCode.value = ''
|
||||
typeNameError.value = null
|
||||
typeCodeError.value = null
|
||||
}
|
||||
|
||||
function openTypeModal() {
|
||||
resetTypeForm()
|
||||
typeModalOpen.value = true
|
||||
}
|
||||
|
||||
function handleTypeModalOpenChange(details: { open: boolean }) {
|
||||
if (savingType.value && !details.open) {
|
||||
typeModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
typeModalOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
resetTypeForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActivityTypes() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
activityTypes.value = await listActivityTypes()
|
||||
syncSelectInitial()
|
||||
|
||||
if (props.autoSelectFirst && !model.value && activityTypes.value.length > 0) {
|
||||
model.value = activityTypes.value[0]!.id
|
||||
syncSelectInitial()
|
||||
}
|
||||
} catch {
|
||||
activityTypes.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateType() {
|
||||
if (savingType.value) return
|
||||
|
||||
const trimmedName = typeName.value.trim()
|
||||
const trimmedCode = typeCode.value.trim()
|
||||
|
||||
typeNameError.value = null
|
||||
typeCodeError.value = null
|
||||
|
||||
if (!trimmedName) {
|
||||
typeNameError.value = 'Nama jenis aktiviti diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
savingType.value = true
|
||||
|
||||
try {
|
||||
const created = await createActivityType({
|
||||
name: trimmedName,
|
||||
code: trimmedCode || null,
|
||||
})
|
||||
|
||||
activityTypes.value = [...activityTypes.value, created]
|
||||
model.value = created.id
|
||||
syncSelectInitial()
|
||||
|
||||
typeModalOpen.value = false
|
||||
resetTypeForm()
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.name?.[0]) {
|
||||
typeNameError.value = validationErrors.name[0]
|
||||
}
|
||||
if (validationErrors?.code?.[0]) {
|
||||
typeCodeError.value = validationErrors.code[0]
|
||||
}
|
||||
if (!typeNameError.value && !typeCodeError.value) {
|
||||
typeNameError.value = getApiErrorMessage(err, 'Gagal menambah jenis aktiviti.')
|
||||
}
|
||||
} finally {
|
||||
savingType.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
if (activityTypes.value.length) {
|
||||
syncSelectInitial()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivityTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Aktiviti</FieldLabel>
|
||||
<div class="flex items-start gap-2">
|
||||
<SelectRoot
|
||||
:key="selectKey"
|
||||
class="min-w-0 flex-1"
|
||||
:collection="activityTypeCollection"
|
||||
:default-value="selectInitial"
|
||||
:disabled="props.disabled || loading || !activityTypeOptions.length"
|
||||
@value-change="setActivityTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText
|
||||
:placeholder="loading ? 'Memuatkan jenis aktiviti...' : 'Pilih jenis aktiviti'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activityTypeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<Button
|
||||
v-if="canCreateType"
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
class="shrink-0"
|
||||
:disabled="props.disabled || savingType"
|
||||
title="Tambah jenis aktiviti"
|
||||
@click="openTypeModal"
|
||||
>
|
||||
<Lucide class="size-4" icon="Plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<DialogRoot :open="typeModalOpen" @openChange="handleTypeModalOpenChange">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogTitle>Tambah Jenis Aktiviti</DialogTitle>
|
||||
<DialogDescription>
|
||||
Daftar jenis aktiviti baharu untuk digunakan dalam borang ini.
|
||||
</DialogDescription>
|
||||
|
||||
<form class="mt-4 space-y-4" @submit.prevent="handleCreateType">
|
||||
<Field>
|
||||
<FieldLabel for="activity-type-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="activity-type-name"
|
||||
v-model="typeName"
|
||||
placeholder="Contoh: Mesyuarat Agung"
|
||||
:disabled="savingType"
|
||||
required
|
||||
/>
|
||||
<FieldError v-if="typeNameError">{{ typeNameError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-type-code">Kod</FieldLabel>
|
||||
<Input
|
||||
id="activity-type-code"
|
||||
v-model="typeCode"
|
||||
placeholder="Contoh: MA (pilihan)"
|
||||
:disabled="savingType"
|
||||
/>
|
||||
<FieldError v-if="typeCodeError">{{ typeCodeError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<DialogCloseTrigger look="outline" variant="secondary" :disabled="savingType">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button type="submit" variant="primary" :disabled="savingType">
|
||||
{{ savingType ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getActivity } from '../services/activity.service'
|
||||
import type { ActivityDetail } from '../types/activity.types'
|
||||
|
||||
export function useActivityDetail() {
|
||||
const route = useRoute()
|
||||
const activity = ref<ActivityDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const activityId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
async function fetchActivity() {
|
||||
if (!activityId.value) {
|
||||
error.value = 'ID aktiviti tidak sah.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
activity.value = await getActivity(activityId.value)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran aktiviti.')
|
||||
activity.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activityId, () => {
|
||||
fetchActivity()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivity()
|
||||
})
|
||||
|
||||
return {
|
||||
activity,
|
||||
activityId,
|
||||
loading,
|
||||
error,
|
||||
fetchActivity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listActivities } from '../services/activity.service'
|
||||
import type { ActivityListItem } from '../types/activity.types'
|
||||
|
||||
export function useActivityList() {
|
||||
const activities = ref<ActivityListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
const sortBy = ref('start_datetime')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchActivities(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listActivities({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
activities.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai aktiviti.')
|
||||
activities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchActivities(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([sortBy, sortOrder], () => {
|
||||
fetchActivities(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchActivities(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchActivities(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivities(1)
|
||||
})
|
||||
|
||||
return {
|
||||
activities,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination,
|
||||
fetchActivities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activityLayoutRoutes } from './routes'
|
||||
export { activityMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const activityMenu: Menu[] = [
|
||||
{
|
||||
icon: 'CalendarDays',
|
||||
route_name: 'list-activities',
|
||||
title: 'Aktiviti',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,405 @@
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityTypeSelectField from '../components/ActivityTypeSelectField.vue'
|
||||
import { createActivity } from '../services/activity.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type StagedGalleryFile = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
type StagedDocumentFile = {
|
||||
id: string
|
||||
file: File
|
||||
}
|
||||
|
||||
const ACTIVE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Aktif', value: '1' },
|
||||
{ label: 'Tidak Aktif', value: '0' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const galleryFiles = ref<StagedGalleryFile[]>([])
|
||||
const documentFiles = ref<StagedDocumentFile[]>([])
|
||||
|
||||
const form = reactive({
|
||||
activity_type_id: '',
|
||||
title: '',
|
||||
reference_number: '',
|
||||
description: '',
|
||||
start_datetime: '',
|
||||
end_datetime: '',
|
||||
organizer: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const activeStatusCollection = createSelectCollection(ACTIVE_STATUS_OPTIONS)
|
||||
|
||||
const activeStatusInitial = ref<string[]>(['Aktif'])
|
||||
|
||||
function setActiveStatusValue(details: { value: string[] }) {
|
||||
form.is_active = labelToValue(ACTIVE_STATUS_OPTIONS, details.value[0]) === '1'
|
||||
}
|
||||
|
||||
function onGalleryFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
galleryFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
documentFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function removeGalleryFile(id: string) {
|
||||
const item = galleryFiles.value.find((file) => file.id === id)
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
}
|
||||
galleryFiles.value = galleryFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function removeDocumentFile(id: string) {
|
||||
documentFiles.value = documentFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function revokeGalleryPreviewUrls() {
|
||||
galleryFiles.value.forEach((file) => URL.revokeObjectURL(file.previewUrl))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.activity_type_id) {
|
||||
error.value = 'Sila pilih jenis aktiviti.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const activity = await createActivity(
|
||||
{
|
||||
activity_type_id: form.activity_type_id,
|
||||
title: form.title.trim(),
|
||||
reference_number: form.reference_number.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
start_datetime: form.start_datetime || null,
|
||||
end_datetime: form.end_datetime || null,
|
||||
organizer: form.organizer.trim() || null,
|
||||
location: form.location.trim() || null,
|
||||
is_active: form.is_active,
|
||||
},
|
||||
{
|
||||
gallery: galleryFiles.value.map((item) => item.file),
|
||||
documents: documentFiles.value.map((item) => item.file),
|
||||
},
|
||||
)
|
||||
|
||||
successMessage.value = 'Aktiviti berjaya didaftarkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-activity', params: { id: activity.id } })
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mendaftar aktiviti.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-activities' })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeGalleryPreviewUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Daftar Aktiviti</h2>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-title">Tajuk</FieldLabel>
|
||||
<Input id="activity-title" v-model="form.title" required :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<ActivityTypeSelectField
|
||||
v-model="form.activity_type_id"
|
||||
:disabled="saving"
|
||||
auto-select-first
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="activeStatusCollection"
|
||||
:default-value="activeStatusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setActiveStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activeStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-reference">No. Rujukan</FieldLabel>
|
||||
<Input
|
||||
id="activity-reference"
|
||||
v-model="form.reference_number"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-organizer">Penganjur</FieldLabel>
|
||||
<Input id="activity-organizer" v-model="form.organizer" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-location">Lokasi</FieldLabel>
|
||||
<Input id="activity-location" v-model="form.location" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-start">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="activity-start"
|
||||
v-model="form.start_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-end">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="activity-end"
|
||||
v-model="form.end_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-description">Penerangan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah imej beberapa kali sebelum mendaftar. Imej dimuat naik semasa pendaftaran.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="galleryFiles.length"
|
||||
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="image in galleryFiles"
|
||||
:key="image.id"
|
||||
class="relative overflow-hidden rounded-lg border border-foreground/10"
|
||||
>
|
||||
<img
|
||||
:src="image.previewUrl"
|
||||
:alt="image.file.name"
|
||||
class="aspect-square w-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2"
|
||||
:disabled="saving"
|
||||
@click="removeGalleryFile(image.id)"
|
||||
>
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-gallery-upload">Tambah Imej Galeri</FieldLabel>
|
||||
<input
|
||||
id="activity-gallery-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="saving"
|
||||
@change="onGalleryFilesChange"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah dokumen beberapa kali sebelum mendaftar. Dokumen dimuat naik semasa pendaftaran.
|
||||
</p>
|
||||
|
||||
<div v-if="documentFiles.length" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="document in documentFiles"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.file.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.file.type || 'Dokumen' }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="removeDocumentFile(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-documents-upload">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="activity-documents-upload"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="goBack">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving || !form.activity_type_id">
|
||||
{{ saving ? 'Menyimpan...' : 'Daftar Aktiviti' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,547 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, Download, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from '@/components/ui/alert'
|
||||
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 {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselIndicatorGroup,
|
||||
CarouselIndicator,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityReportsSection from '../components/ActivityReportsSection.vue'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import {
|
||||
deleteActivity,
|
||||
downloadActivityDocument,
|
||||
fetchActivityDocument,
|
||||
} from '../services/activity.service'
|
||||
import type { ActivityDocument } from '../types/activity.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { activity, loading, error, fetchActivity } = useActivityDetail()
|
||||
|
||||
const canEdit = computed(() => hasPermission('kemaskini aktiviti'))
|
||||
const canDelete = computed(() => hasPermission('hapus aktiviti'))
|
||||
|
||||
const galleryImages = computed(() => activity.value?.gallery_images ?? [])
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const deletingActivity = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const documentPreviewOpen = ref(false)
|
||||
const previewDocument = ref<ActivityDocument | null>(null)
|
||||
const documentPreviewUrl = ref<string | null>(null)
|
||||
const documentPreviewLoading = ref(false)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
|
||||
const isDocumentPreviewImage = computed(() =>
|
||||
previewDocument.value?.mime_type?.startsWith('image/') ?? false,
|
||||
)
|
||||
|
||||
const isDocumentPreviewPdf = computed(() =>
|
||||
previewDocument.value?.mime_type === 'application/pdf',
|
||||
)
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function documentLabel(document: ActivityDocument): string {
|
||||
if (document.type && document.type !== 'general') {
|
||||
return document.type
|
||||
}
|
||||
return document.name
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null) {
|
||||
if (url) {
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function revokeDocumentPreviewUrl() {
|
||||
revokeUrl(documentPreviewUrl.value)
|
||||
documentPreviewUrl.value = null
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: ActivityDocument) {
|
||||
if (!activity.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadActivityDocument(
|
||||
activity.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: ActivityDocument) {
|
||||
if (!activity.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
documentPreviewOpen.value = true
|
||||
documentPreviewLoading.value = true
|
||||
revokeDocumentPreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchActivityDocument(
|
||||
activity.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
documentPreviewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
closeDocumentPreview()
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan dokumen.')
|
||||
} finally {
|
||||
documentPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewOpen.value = false
|
||||
previewDocument.value = null
|
||||
revokeDocumentPreviewUrl()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-activities' })
|
||||
}
|
||||
|
||||
function goToEdit() {
|
||||
if (!activity.value) return
|
||||
router.push({ name: 'edit-activity', params: { id: activity.value.id } })
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function openDeleteConfirmation() {
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteConfirmationOpenChange(details: { open: boolean }) {
|
||||
if (deletingActivity.value && !details.open) {
|
||||
deleteConfirmationOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
deleteConfirmationOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
deleteError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteActivity() {
|
||||
if (!activity.value || deletingActivity.value) return
|
||||
|
||||
deletingActivity.value = true
|
||||
deleteError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await deleteActivity(activity.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
router.push({ name: 'list-activities' })
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal memadam aktiviti.')
|
||||
} finally {
|
||||
deletingActivity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeDocumentPreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Aktiviti</h2>
|
||||
<p v-if="activity" class="mt-1 text-sm opacity-70">
|
||||
{{ activity.reference_number ?? activity.id }} · {{ activity.title }}
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
<Button v-if="canEdit" type="button" @click="goToEdit">
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
@click="openDeleteConfirmation"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Trash2" />
|
||||
Hapus
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="clearError" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan butiran aktiviti...</div>
|
||||
|
||||
<template v-else-if="activity">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm opacity-70">Tajuk Aktiviti</div>
|
||||
<div class="text-xl font-semibold">{{ activity.title }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge look="outline" :variant="activity.is_active ? 'success' : 'outline'">
|
||||
{{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</Badge>
|
||||
<Badge v-if="activity.activity_type" look="outline" variant="pending">
|
||||
{{ activity.activity_type.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">No. Rujukan:</span>
|
||||
{{ activity.reference_number ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Penganjur:</span>
|
||||
{{ activity.organizer ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Lokasi:</span>
|
||||
{{ activity.location ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Mula:</span>
|
||||
{{ formatDateTime(activity.start_datetime) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Tamat:</span>
|
||||
{{ formatDateTime(activity.end_datetime) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Dicipta:</span>
|
||||
{{ formatDateTime(activity.created_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Dikemaskini:</span>
|
||||
{{ formatDateTime(activity.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activity.description" class="mt-5 border-t border-foreground/10 pt-5">
|
||||
<div class="text-sm font-medium opacity-70">Penerangan</div>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-relaxed">{{ activity.description }}</p>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="!galleryImages.length" class="p-5 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">0 imej</p>
|
||||
</div>
|
||||
<Lucide class="size-5 opacity-50" icon="Images" />
|
||||
</div>
|
||||
<div class="mt-5 opacity-70">Tiada imej galeri dimuat naik.</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else class="overflow-hidden p-0">
|
||||
<CarouselRoot
|
||||
:default-page="0"
|
||||
:slide-count="galleryImages.length"
|
||||
class="w-full border-0"
|
||||
>
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-4">
|
||||
<div class="mr-auto min-w-0">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">{{ galleryImages.length }} imej</p>
|
||||
</div>
|
||||
<Lucide class="mr-3 size-5 opacity-50" icon="Images" />
|
||||
<template v-if="galleryImages.length > 1">
|
||||
<CarouselPrevTrigger as-child>
|
||||
<Button variant="ghost" class="mr-2 border border-foreground/15 shadow-none" type="button">
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</Button>
|
||||
</CarouselPrevTrigger>
|
||||
<CarouselNextTrigger as-child>
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none" type="button">
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</Button>
|
||||
</CarouselNextTrigger>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-2">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="image.id"
|
||||
:index="index"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div
|
||||
class="flex w-full items-center justify-center overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5"
|
||||
style="min-height: 16rem; max-height: 28rem"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
class="block max-h-112 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 w-full text-center">
|
||||
<div class="truncate text-sm font-medium">{{ image.name }}</div>
|
||||
<div v-if="image.description" class="mt-1 text-xs opacity-70">
|
||||
{{ image.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="galleryImages.length > 1"
|
||||
class="flex justify-center border-t border-foreground/10 px-5 py-4"
|
||||
>
|
||||
<CarouselIndicatorGroup>
|
||||
<CarouselIndicator
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="image.id"
|
||||
:index="index"
|
||||
/>
|
||||
</CarouselIndicatorGroup>
|
||||
</div>
|
||||
</CarouselRoot>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ activity.documents.length }} dokumen · muat turun atau lihat
|
||||
</p>
|
||||
</div>
|
||||
<Lucide class="size-5 opacity-50" icon="Paperclip" />
|
||||
</div>
|
||||
|
||||
<div v-if="!activity.documents.length" class="mt-5 opacity-70">
|
||||
Tiada dokumen lampiran.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="document in activity.documents"
|
||||
:key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Lucide
|
||||
class="size-4 shrink-0 opacity-60"
|
||||
:icon="document.mime_type?.startsWith('image/') ? 'Image' : 'FileText'"
|
||||
/>
|
||||
<div class="truncate font-medium">{{ documentLabel(document) }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ document.name }} · {{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
<div v-if="document.description" class="mt-1 text-sm opacity-70">
|
||||
{{ document.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="documentPreviewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)"
|
||||
>
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{
|
||||
documentPreviewLoading && 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>
|
||||
</Box>
|
||||
|
||||
<ActivityReportsSection
|
||||
v-if="activity"
|
||||
:activity-id="activity.id"
|
||||
:reports="activity.reports ?? []"
|
||||
@changed="fetchActivity"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="documentPreviewOpen"
|
||||
class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="previewDocument ? documentLabel(previewDocument) : 'Pratonton dokumen'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/80"
|
||||
aria-label="Tutup pratonton dokumen"
|
||||
@click="closeDocumentPreview"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument ? documentLabel(previewDocument) : 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="documentPreviewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="documentPreviewUrl && isDocumentPreviewImage" class="flex justify-center">
|
||||
<img
|
||||
:src="documentPreviewUrl"
|
||||
:alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewPdf"
|
||||
:src="documentPreviewUrl"
|
||||
class="block w-full rounded-lg border border-foreground/10"
|
||||
style="height: min(70vh, 720px)"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
/>
|
||||
|
||||
<div v-else-if="documentPreviewUrl" class="py-12 text-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button
|
||||
v-if="previewDocument"
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id"
|
||||
@click="handleDownloadDocument(previewDocument)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
<Button type="button" look="outline" @click="closeDocumentPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="handleDeleteConfirmationOpenChange">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Padam Aktiviti?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu memadam
|
||||
<span v-if="activity" class="font-medium">{{ activity.title }}</span>?
|
||||
<br />
|
||||
Semua dokumen, galeri, dan laporan berkaitan turut akan dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deletingActivity">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deletingActivity"
|
||||
@click="confirmDeleteActivity"
|
||||
>
|
||||
{{ deletingActivity ? 'Memadam...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,504 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityReportsSection from '../components/ActivityReportsSection.vue'
|
||||
import ActivityTypeSelectField from '../components/ActivityTypeSelectField.vue'
|
||||
import {
|
||||
deleteActivityDocument,
|
||||
deleteActivityGalleryImage,
|
||||
getActivity,
|
||||
updateActivity,
|
||||
uploadActivityDocument,
|
||||
uploadActivityGalleryImage,
|
||||
} from '../services/activity.service'
|
||||
import type { ActivityDetail } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const ACTIVE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Aktif', value: '1' },
|
||||
{ label: 'Tidak Aktif', value: '0' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(value: string | null): string {
|
||||
if (!value) return ''
|
||||
return dayjs(value).format('YYYY-MM-DDTHH:mm')
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const activityId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploadingGallery = ref(false)
|
||||
const uploadingDocuments = ref(false)
|
||||
const deletingAssetId = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const activity = ref<ActivityDetail | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
activity_type_id: '',
|
||||
title: '',
|
||||
reference_number: '',
|
||||
description: '',
|
||||
start_datetime: '',
|
||||
end_datetime: '',
|
||||
organizer: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const activeStatusCollection = createSelectCollection(ACTIVE_STATUS_OPTIONS)
|
||||
|
||||
const activeStatusInitial = ref<string[]>([])
|
||||
|
||||
function setActiveStatusValue(details: { value: string[] }) {
|
||||
form.is_active = labelToValue(ACTIVE_STATUS_OPTIONS, details.value[0]) === '1'
|
||||
}
|
||||
|
||||
function syncFormFromActivity(data: ActivityDetail) {
|
||||
form.activity_type_id = data.activity_type_id
|
||||
form.title = data.title
|
||||
form.reference_number = data.reference_number ?? ''
|
||||
form.description = data.description ?? ''
|
||||
form.start_datetime = toDatetimeLocalValue(data.start_datetime)
|
||||
form.end_datetime = toDatetimeLocalValue(data.end_datetime)
|
||||
form.organizer = data.organizer ?? ''
|
||||
form.location = data.location ?? ''
|
||||
form.is_active = data.is_active
|
||||
|
||||
activeStatusInitial.value = valueToLabel(
|
||||
ACTIVE_STATUS_OPTIONS,
|
||||
data.is_active ? '1' : '0',
|
||||
)
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
const data = await getActivity(activityId.value)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await loadActivity()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat aktiviti.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showTransientSuccess(message: string) {
|
||||
successMessage.value = message
|
||||
setTimeout(() => {
|
||||
if (successMessage.value === message) {
|
||||
successMessage.value = null
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
async function onGalleryFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length || uploadingGallery.value) return
|
||||
|
||||
uploadingGallery.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const data = await uploadActivityGalleryImage(activityId.value, file)
|
||||
activity.value = data
|
||||
}
|
||||
|
||||
showTransientSuccess(
|
||||
files.length === 1
|
||||
? 'Imej galeri berjaya dimuat naik.'
|
||||
: `${files.length} imej galeri berjaya dimuat naik.`,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik imej galeri.')
|
||||
} finally {
|
||||
uploadingGallery.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length || uploadingDocuments.value) return
|
||||
|
||||
uploadingDocuments.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const data = await uploadActivityDocument(activityId.value, file)
|
||||
activity.value = data
|
||||
}
|
||||
|
||||
showTransientSuccess(
|
||||
files.length === 1
|
||||
? 'Dokumen berjaya dimuat naik.'
|
||||
: `${files.length} dokumen berjaya dimuat naik.`,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik dokumen.')
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateActivity(activityId.value, {
|
||||
activity_type_id: form.activity_type_id,
|
||||
title: form.title.trim(),
|
||||
reference_number: form.reference_number.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
start_datetime: form.start_datetime || null,
|
||||
end_datetime: form.end_datetime || null,
|
||||
organizer: form.organizer.trim() || null,
|
||||
location: form.location.trim() || null,
|
||||
is_active: form.is_active,
|
||||
})
|
||||
|
||||
successMessage.value = 'Aktiviti berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-activity', params: { id: activityId.value } })
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini aktiviti.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGalleryImage(imageId: string) {
|
||||
if (deletingAssetId.value || uploadingGallery.value) return
|
||||
|
||||
deletingAssetId.value = imageId
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await deleteActivityGalleryImage(activityId.value, imageId)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
showTransientSuccess('Imej galeri berjaya dipadam.')
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam imej galeri.')
|
||||
} finally {
|
||||
deletingAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument(documentId: string) {
|
||||
if (deletingAssetId.value || uploadingDocuments.value) return
|
||||
|
||||
deletingAssetId.value = documentId
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await deleteActivityDocument(activityId.value, documentId)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
showTransientSuccess('Dokumen berjaya dipadam.')
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam dokumen.')
|
||||
} finally {
|
||||
deletingAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'view-activity', params: { id: activityId.value } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Aktiviti</h2>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan maklumat aktiviti...</div>
|
||||
|
||||
<form v-else-if="activity" class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-title">Tajuk</FieldLabel>
|
||||
<Input id="activity-title" v-model="form.title" required :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<ActivityTypeSelectField v-model="form.activity_type_id" :disabled="saving" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="activeStatusInitial[0]"
|
||||
class="w-full"
|
||||
:collection="activeStatusCollection"
|
||||
:default-value="activeStatusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setActiveStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activeStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-reference">No. Rujukan</FieldLabel>
|
||||
<Input
|
||||
id="activity-reference"
|
||||
v-model="form.reference_number"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-organizer">Penganjur</FieldLabel>
|
||||
<Input id="activity-organizer" v-model="form.organizer" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-location">Lokasi</FieldLabel>
|
||||
<Input id="activity-location" v-model="form.location" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-start">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="activity-start"
|
||||
v-model="form.start_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-end">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="activity-end"
|
||||
v-model="form.end_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-description">Penerangan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Imej dimuat naik serta-merta apabila dipilih. Padam imej tanpa perlu simpan borang.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="activity.gallery_images.length"
|
||||
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="image in activity.gallery_images"
|
||||
:key="image.id"
|
||||
class="relative overflow-hidden rounded-lg border border-foreground/10"
|
||||
>
|
||||
<img :src="image.url" :alt="image.name" class="aspect-square w-full object-cover" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2"
|
||||
:disabled="deletingAssetId === image.id || uploadingGallery || saving"
|
||||
@click="handleDeleteGalleryImage(image.id)"
|
||||
>
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-gallery-upload">Tambah Imej Galeri</FieldLabel>
|
||||
<input
|
||||
id="activity-gallery-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="uploadingGallery || saving"
|
||||
@change="onGalleryFilesChange"
|
||||
/>
|
||||
<p v-if="uploadingGallery" class="mt-2 text-sm opacity-70">Memuat naik imej...</p>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Dokumen dimuat naik serta-merta apabila dipilih. Padam dokumen tanpa perlu simpan borang.
|
||||
</p>
|
||||
|
||||
<div v-if="activity.documents.length" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="document in activity.documents"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.mime_type }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="deletingAssetId === document.id || uploadingDocuments || saving"
|
||||
@click="handleDeleteDocument(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-documents-upload">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="activity-documents-upload"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="uploadingDocuments || saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
<p v-if="uploadingDocuments" class="mt-2 text-sm opacity-70">Memuat naik dokumen...</p>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<ActivityReportsSection
|
||||
v-if="activity"
|
||||
:activity-id="activity.id"
|
||||
:reports="activity.reports ?? []"
|
||||
@changed="loadActivity"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="goBack">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
} from '@/components/ui/menu'
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationRoot,
|
||||
PaginationItem,
|
||||
PaginationPrevTrigger,
|
||||
PaginationNextTrigger,
|
||||
PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useActivityList } from '../composables/useActivityList'
|
||||
import type { ActivityListItem } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const PER_PAGE_OPTIONS: SelectOption[] = [
|
||||
{ label: '10', value: '10' },
|
||||
{ label: '25', value: '25' },
|
||||
{ label: '35', value: '35' },
|
||||
{ label: '50', value: '50' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tarikh Mula (Terbaharu)', value: 'start_datetime:desc' },
|
||||
{ label: 'Tarikh Mula (Terlama)', value: 'start_datetime:asc' },
|
||||
{ label: 'Tajuk (A-Z)', value: 'title:asc' },
|
||||
{ label: 'Tajuk (Z-A)', value: 'title:desc' },
|
||||
{ label: 'Dicipta (Terbaharu)', value: 'created_at:desc' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const perPageCollection = createSelectCollection(PER_PAGE_OPTIONS)
|
||||
const sortCollection = createSelectCollection(SORT_OPTIONS)
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
const canCreate = computed(() => hasPermission('tambah aktiviti'))
|
||||
const canEdit = computed(() => hasPermission('kemaskini aktiviti'))
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
activities,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination,
|
||||
} = useActivityList()
|
||||
|
||||
const perPageInitial = computed(() => valueToLabel(PER_PAGE_OPTIONS, String(itemsPerPage.value)))
|
||||
const sortInitial = computed(() => valueToLabel(SORT_OPTIONS, `${sortBy.value}:${sortOrder.value}`))
|
||||
|
||||
function handlePageChange(details: { page: number }) {
|
||||
page.value = details.page
|
||||
}
|
||||
|
||||
function setPerPageValue(details: { value: string[] }) {
|
||||
const next = Number(labelToValue(PER_PAGE_OPTIONS, details.value[0]))
|
||||
if (!Number.isNaN(next)) {
|
||||
itemsPerPage.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function setSortValue(details: { value: string[] }) {
|
||||
const raw = labelToValue(SORT_OPTIONS, details.value[0])
|
||||
const [field, order] = raw.split(':')
|
||||
if (field) sortBy.value = field
|
||||
if (order === 'asc' || order === 'desc') sortOrder.value = order
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function coverImageUrl(activity: ActivityListItem): string | null {
|
||||
return activity.cover_image?.url ?? null
|
||||
}
|
||||
|
||||
function truncateText(value: string | null, length = 140): string {
|
||||
if (!value) return ''
|
||||
return value.length > length ? `${value.slice(0, length).trim()}...` : value
|
||||
}
|
||||
|
||||
function goToActivity(id: string) {
|
||||
router.push({ name: 'view-activity', params: { id } })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-activity', params: { id } })
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-activity' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Senarai Aktiviti</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan lihat aktiviti koperasi.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
class="mr-2 shadow-sm"
|
||||
variant="primary"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Aktiviti
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Input v-model="search" class="pr-10" type="search" placeholder="Cari aktiviti..." />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 size-4 opacity-70" icon="Search" />
|
||||
</div>
|
||||
|
||||
<SelectRoot
|
||||
:key="sortInitial[0]"
|
||||
class="w-full sm:w-56"
|
||||
:collection="sortCollection"
|
||||
:default-value="sortInitial"
|
||||
@value-change="setSortValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Susunan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in sortCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<Box
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
class="col-span-12 animate-pulse p-0 md:col-span-6 xl:col-span-4"
|
||||
>
|
||||
<div class="h-64 bg-foreground/5" />
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Box
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="col-span-12 cursor-pointer p-0 md:col-span-6 xl:col-span-4"
|
||||
@click="goToActivity(activity.id)"
|
||||
>
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-4">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
<Lucide class="size-5" icon="CalendarDays" />
|
||||
</div>
|
||||
<div class="ml-3 mr-auto min-w-0">
|
||||
<div class="truncate font-medium">
|
||||
{{ activity.activity_type?.name ?? 'Aktiviti' }}
|
||||
</div>
|
||||
<div class="mt-0.5 flex truncate text-xs opacity-70">
|
||||
<span v-if="activity.reference_number" class="text-primary inline-block truncate">
|
||||
{{ activity.reference_number }}
|
||||
</span>
|
||||
<span v-if="activity.reference_number" class="mx-1">•</span>
|
||||
<span>{{ formatDateTime(activity.start_datetime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge look="outline" :variant="activity.is_active ? 'success' : 'outline'">
|
||||
{{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="image-fit h-40 overflow-hidden rounded-md bg-foreground/5 2xl:h-56">
|
||||
<img
|
||||
v-if="coverImageUrl(activity)"
|
||||
class="size-full object-cover"
|
||||
:src="coverImageUrl(activity)!"
|
||||
:alt="activity.title"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Lucide class="size-8 opacity-40" icon="Image" />
|
||||
<span class="text-xs opacity-70">Tiada imej</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 block text-base font-medium">
|
||||
{{ activity.title }}
|
||||
</div>
|
||||
<div v-if="activity.description" class="mt-2 line-clamp-3 text-foreground/70">
|
||||
{{ truncateText(activity.description) }}
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs opacity-70">
|
||||
<div v-if="activity.organizer" class="flex items-center gap-2">
|
||||
<Lucide class="size-3.5 shrink-0" icon="Users" />
|
||||
<span class="truncate">{{ activity.organizer }}</span>
|
||||
</div>
|
||||
<div v-if="activity.location" class="flex items-center gap-2">
|
||||
<Lucide class="size-3.5 shrink-0" icon="MapPin" />
|
||||
<span class="truncate">{{ activity.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center border-t border-foreground/15 px-5 py-3">
|
||||
<div class="mr-2 flex items-center gap-1 text-xs opacity-70">
|
||||
<Lucide class="size-3.5" icon="Images" />
|
||||
<span>{{ activity.gallery_image_count }} imej</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs opacity-70">
|
||||
<Lucide class="size-3.5" icon="Paperclip" />
|
||||
<span>{{ activity.documents_count }} dokumen</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto w-auto" @click.stop>
|
||||
<MenuRoot>
|
||||
<MenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreVertical" />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="view" @click="goToActivity(activity.id)">
|
||||
<Lucide class="mr-2 size-4" icon="Eye" /> Lihat
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
v-if="canEdit"
|
||||
value="edit"
|
||||
@click="goToEdit(activity.id)"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Edit" /> Edit
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading && !activities.length" class="col-span-12">
|
||||
<Box class="p-6 text-center opacity-70">Tiada aktiviti ditemui.</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot
|
||||
:count="pagination.total"
|
||||
:page="page"
|
||||
:page-size="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
:on-page-change="handlePageChange"
|
||||
class="w-full sm:mr-auto sm:w-auto"
|
||||
>
|
||||
<PaginationPrevTrigger>
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</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 }">
|
||||
{{ pageItem.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else :index="index" />
|
||||
</template>
|
||||
</PaginationContext>
|
||||
<PaginationNextTrigger>
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</PaginationNextTrigger>
|
||||
</PaginationRoot>
|
||||
|
||||
<SelectRoot
|
||||
:key="perPageInitial[0]"
|
||||
class="mt-3 w-20 sm:mt-0"
|
||||
:collection="perPageCollection"
|
||||
:default-value="perPageInitial"
|
||||
@value-change="setPerPageValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in perPageCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const activityLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'activities',
|
||||
name: 'list-activities',
|
||||
component: () => import('./pages/ActivityList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/create',
|
||||
name: 'create-activity',
|
||||
component: () => import('./pages/ActivityCreate.vue'),
|
||||
meta: {
|
||||
title: 'Daftar Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'tambah aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/:id',
|
||||
name: 'view-activity',
|
||||
component: () => import('./pages/ActivityDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/:id/edit',
|
||||
name: 'edit-activity',
|
||||
component: () => import('./pages/ActivityEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'kemaskini aktiviti',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
ActivityDetail,
|
||||
ActivityListItem,
|
||||
ActivityReport,
|
||||
ActivityType,
|
||||
CreateActivityReportPayload,
|
||||
CreateActivityPayload,
|
||||
CreateActivityTypePayload,
|
||||
ListActivitiesParams,
|
||||
UpdateActivityFiles,
|
||||
UpdateActivityPayload,
|
||||
UpdateActivityReportPayload,
|
||||
} from '../types/activity.types'
|
||||
|
||||
type ActivityApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ActivityReportApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityReport
|
||||
message?: string
|
||||
}
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildActivityFormData(
|
||||
payload: UpdateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
options: { method?: 'PUT' } = {},
|
||||
): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('activity_type_id', payload.activity_type_id)
|
||||
formData.append('title', payload.title)
|
||||
formData.append('is_active', payload.is_active ? '1' : '0')
|
||||
appendIfPresent(formData, 'reference_number', payload.reference_number ?? undefined)
|
||||
appendIfPresent(formData, 'description', payload.description ?? undefined)
|
||||
appendIfPresent(formData, 'start_datetime', payload.start_datetime ?? undefined)
|
||||
appendIfPresent(formData, 'end_datetime', payload.end_datetime ?? undefined)
|
||||
appendIfPresent(formData, 'organizer', payload.organizer ?? undefined)
|
||||
appendIfPresent(formData, 'location', payload.location ?? undefined)
|
||||
|
||||
files.documents?.forEach((file, index) => {
|
||||
formData.append(`documents[${index}]`, file)
|
||||
})
|
||||
|
||||
files.gallery?.forEach((file, index) => {
|
||||
formData.append(`gallery[${index}]`, file)
|
||||
})
|
||||
|
||||
if (options.method === 'PUT') {
|
||||
formData.append('_method', 'PUT')
|
||||
}
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
export async function listActivities(
|
||||
params: ListActivitiesParams,
|
||||
): Promise<PaginatedApiResponse<ActivityListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityListItem>>('/v1/activities', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai aktiviti.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listActivityTypes(): Promise<ActivityType[]> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityType>>('/v1/activity-types', {
|
||||
params: { per_page: 1000 },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan jenis aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
type ActivityTypeApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityType
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function createActivityType(
|
||||
payload: CreateActivityTypePayload,
|
||||
): Promise<ActivityType> {
|
||||
const { data } = await api.post<ActivityTypeApiResponse>('/v1/activity-types', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah jenis aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getActivity(id: string): Promise<ActivityDetail> {
|
||||
const { data } = await api.get<ActivityApiResponse>(`/v1/activities/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan butiran aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateActivity(
|
||||
id: string,
|
||||
payload: UpdateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = buildActivityFormData(payload, files, { method: 'PUT' })
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(`/v1/activities/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createActivity(
|
||||
payload: CreateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = buildActivityFormData(payload, files)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>('/v1/activities', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mendaftar aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadActivityGalleryImage(
|
||||
activityId: string,
|
||||
file: File,
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/gallery`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik imej galeri.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadActivityDocument(
|
||||
activityId: string,
|
||||
file: File,
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/documents`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik dokumen.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteActivityGalleryImage(
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
): Promise<ActivityDetail> {
|
||||
const { data } = await api.delete<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/gallery/${imageId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam imej galeri.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
): Promise<ActivityDetail> {
|
||||
const { data } = await api.delete<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/documents/${documentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam dokumen.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchActivityGalleryImage(
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(`/v1/activities/${activityId}/gallery/${imageId}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function fetchActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(
|
||||
`/v1/activities/${activityId}/documents/${documentId}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function downloadActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<void> {
|
||||
const blob = await fetchActivityDocument(activityId, documentId, mimeType)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function createActivityReport(
|
||||
payload: CreateActivityReportPayload,
|
||||
): Promise<ActivityReport> {
|
||||
const { data } = await api.post<ActivityReportApiResponse>('/v1/activity-reports', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah laporan aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateActivityReport(
|
||||
id: string,
|
||||
payload: UpdateActivityReportPayload,
|
||||
): Promise<ActivityReport> {
|
||||
const { data } = await api.put<ActivityReportApiResponse>(`/v1/activity-reports/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini laporan aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
type ActivityDeleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function deleteActivity(id: string): Promise<void> {
|
||||
const { data } = await api.delete<ActivityDeleteResponse>(`/v1/activities/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam aktiviti.')
|
||||
}
|
||||
}
|
||||
|
||||
type ActivityReportDeleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function deleteActivityReport(id: string): Promise<void> {
|
||||
const { data } = await api.delete<ActivityReportDeleteResponse>(`/v1/activity-reports/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam laporan aktiviti.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type ActivityType = {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CreateActivityTypePayload = {
|
||||
name: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
export type ActivityGalleryImage = {
|
||||
id: string
|
||||
activity_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
mime_type: string
|
||||
file_size: number
|
||||
uploaded_by: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ActivityDocument = {
|
||||
id: string
|
||||
activity_id: string
|
||||
name: string
|
||||
type: string | null
|
||||
description: string | null
|
||||
mime_type: string
|
||||
file_size: number
|
||||
uploaded_by: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ActivityReport = {
|
||||
id: string
|
||||
activity_id: string
|
||||
report_text: string
|
||||
prepared_by: string
|
||||
prepared_by_user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
} | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActivityDetail = {
|
||||
id: string
|
||||
activity_type_id: string
|
||||
activity_type: ActivityType | null
|
||||
title: string
|
||||
reference_number: string | null
|
||||
description: string | null
|
||||
start_datetime: string | null
|
||||
end_datetime: string | null
|
||||
organizer: string | null
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
documents: ActivityDocument[]
|
||||
gallery_images: ActivityGalleryImage[]
|
||||
reports: ActivityReport[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActivityListItem = {
|
||||
id: string
|
||||
activity_type_id: string
|
||||
activity_type: ActivityType | null
|
||||
title: string
|
||||
reference_number: string | null
|
||||
description: string | null
|
||||
start_datetime: string | null
|
||||
end_datetime: string | null
|
||||
organizer: string | null
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
gallery_image_count: number
|
||||
documents_count: number
|
||||
cover_image: ActivityGalleryImage | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ListActivitiesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type UpdateActivityPayload = {
|
||||
activity_type_id: string
|
||||
title: string
|
||||
reference_number?: string | null
|
||||
description?: string | null
|
||||
start_datetime?: string | null
|
||||
end_datetime?: string | null
|
||||
organizer?: string | null
|
||||
location?: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export type CreateActivityPayload = UpdateActivityPayload
|
||||
|
||||
export type UpdateActivityFiles = {
|
||||
documents?: File[]
|
||||
gallery?: File[]
|
||||
}
|
||||
|
||||
export type CreateActivityReportPayload = {
|
||||
activity_id: string
|
||||
report_text: string
|
||||
}
|
||||
|
||||
export type UpdateActivityReportPayload = {
|
||||
activity_id: string
|
||||
report_text: string
|
||||
}
|
||||
@@ -6,10 +6,14 @@ export {
|
||||
register,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
fetchCurrentUser,
|
||||
getAuthErrorMessage,
|
||||
getRegisterErrorMessage,
|
||||
getVerifyEmailErrorMessage,
|
||||
getForgotPasswordErrorMessage,
|
||||
getResetPasswordErrorMessage,
|
||||
resolvePostLoginRoute,
|
||||
resolvePostAuthRoute,
|
||||
isAccountPending,
|
||||
@@ -22,6 +26,10 @@ export type {
|
||||
RegisterResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
ForgotPasswordPayload,
|
||||
ForgotPasswordResponse,
|
||||
ResetPasswordPayload,
|
||||
ResetPasswordResponse,
|
||||
SessionResponse,
|
||||
AuthUser,
|
||||
AuthRole,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
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'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await requestForgotPassword({ email: email.value })
|
||||
successMessage.value = response.message
|
||||
|
||||
await router.push({
|
||||
name: 'reset-password',
|
||||
query: { email: email.value },
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = getForgotPasswordErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||
]">
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
]">
|
||||
<div class="container relative z-10 mx-auto sm:px-20">
|
||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
||||
<div class="hidden min-h-screen flex-col xl:flex">
|
||||
<div class="my-auto">
|
||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
||||
<div class="mt-10 text-4xl font-medium leading-tight text-white">
|
||||
Lupa Kata Laluan
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Kod OTP akan dihantar jika e-mel wujud dalam sistem.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
||||
<Box raised="double"
|
||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">
|
||||
Lupa Kata Laluan
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Masukkan e-mel akaun anda. Kod OTP akan dihantar jika e-mel wujud dalam sistem.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleSubmit">
|
||||
<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 />
|
||||
|
||||
<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">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
|
||||
</Button>
|
||||
<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>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
@@ -23,9 +24,20 @@ const password = ref('')
|
||||
const remember = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.reset === 'success') {
|
||||
const message = route.query.message
|
||||
successMessage.value = typeof message === 'string' && message
|
||||
? message
|
||||
: 'Kata laluan anda telah berjaya ditetapkan semula. Sila log masuk.'
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
@@ -78,7 +90,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
Sistem Informasi Koperasi Online (MyKOPKB) 1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +104,10 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
Selamat Datang
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Login failed</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
@@ -109,7 +125,13 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
<CheckboxLabel>Ingat saya</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
<a class="opacity-70" href="">Lupa Password?</a>
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })"
|
||||
>
|
||||
Lupa Password?
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="login-button box w-full px-4 py-5" variant="primary" type="submit" :disabled="loading">
|
||||
|
||||
@@ -70,7 +70,7 @@ const handleRegister = async () => {
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
Sistem Informasi Koperasi Online (MyKOPKB) 1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import {
|
||||
getForgotPasswordErrorMessage,
|
||||
getResetPasswordErrorMessage,
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
} from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const otp = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirmation = ref('')
|
||||
const loading = ref(false)
|
||||
const resendLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const resendMessage = ref('')
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
email.value.length > 0
|
||||
&& otp.value.length === 6
|
||||
&& password.value.length > 0
|
||||
&& passwordConfirmation.value.length > 0,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const queryEmail = route.query.email
|
||||
if (typeof queryEmail === 'string' && queryEmail) {
|
||||
email.value = queryEmail
|
||||
}
|
||||
})
|
||||
|
||||
const handleReset = async () => {
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await resetPassword({
|
||||
email: email.value,
|
||||
otp: otp.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
})
|
||||
|
||||
await router.push({
|
||||
name: 'login',
|
||||
query: { reset: 'success', message: response.message },
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = getResetPasswordErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email.value) {
|
||||
errorMessage.value = 'Sila masukkan alamat e-mel.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
resendMessage.value = ''
|
||||
resendLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await requestForgotPassword({ email: email.value })
|
||||
resendMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getForgotPasswordErrorMessage(error)
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOtpInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
otp.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||
]">
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
]">
|
||||
<div class="container relative z-10 mx-auto sm:px-20">
|
||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
||||
<div class="hidden min-h-screen flex-col xl:flex">
|
||||
<div class="my-auto">
|
||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
||||
<div class="mt-10 text-4xl font-medium leading-tight text-white">
|
||||
Tetapkan Semula Kata Laluan
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Masukkan kod OTP dan kata laluan baharu anda.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
||||
<Box raised="double"
|
||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">
|
||||
Reset Kata Laluan
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Masukkan kod OTP 6 digit dan kata laluan baharu anda.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="resendMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ resendMessage }}</AlertDescription>
|
||||
</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
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
{{ 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' })"
|
||||
>
|
||||
Kembali ke Log Masuk
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,6 +19,18 @@ export const authPublicRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('./pages/VerifyEmail.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('./pages/ForgotPassword.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('./pages/ResetPassword.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/account-pending',
|
||||
name: 'account-pending',
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import type {
|
||||
ForgotPasswordPayload,
|
||||
ForgotPasswordResponse,
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
LoginVerificationRequiredData,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
ResendVerificationResponse,
|
||||
ResetPasswordPayload,
|
||||
ResetPasswordResponse,
|
||||
SessionResponse,
|
||||
SwitchRoleResponse,
|
||||
VerifyEmailPayload,
|
||||
@@ -33,6 +37,20 @@ export async function resendVerificationEmail(email: string): Promise<ResendVeri
|
||||
return data
|
||||
}
|
||||
|
||||
export async function requestForgotPassword(
|
||||
payload: ForgotPasswordPayload,
|
||||
): Promise<ForgotPasswordResponse> {
|
||||
const { data } = await api.post<ForgotPasswordResponse>('/forgot-password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
payload: ResetPasswordPayload,
|
||||
): Promise<ResetPasswordResponse> {
|
||||
const { data } = await api.post<ResetPasswordResponse>('/reset-password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<SessionResponse> {
|
||||
const { data } = await api.get<SessionResponse>('/v1/me')
|
||||
return data
|
||||
@@ -59,6 +77,14 @@ export function getVerifyEmailErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Email verification failed. Please try again.')
|
||||
}
|
||||
|
||||
export function getForgotPasswordErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
|
||||
}
|
||||
|
||||
export function getResetPasswordErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
|
||||
}
|
||||
|
||||
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||
return user?.status === 'pending'
|
||||
}
|
||||
|
||||
@@ -35,7 +35,14 @@ export interface AuthUser {
|
||||
position: string | null
|
||||
phone_number: string | null
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
join_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
@@ -91,3 +98,24 @@ export interface ResendVerificationResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ForgotPasswordPayload {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ForgotPasswordResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordPayload {
|
||||
email: string
|
||||
otp: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listMembershipApplications } from '../services/membership-application.service'
|
||||
import type { MembershipApplicationListItem } from '../types/membership-application.types'
|
||||
|
||||
export function useMembershipApplicationList() {
|
||||
const applications = ref<MembershipApplicationListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'submitted_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchApplications(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listMembershipApplications({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'submitted_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
applications.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai permohonan keahlian.')
|
||||
applications.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchApplications(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchApplications(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchApplications(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchApplications(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchApplications(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications(1)
|
||||
})
|
||||
|
||||
return {
|
||||
applications,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchApplications,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { membershipApplicationPublicRoutes, membershipApplicationLayoutRoutes } from './routes'
|
||||
export { membershipApplicationMenu } from './menu'
|
||||
export { submitMembershipApplication, listMembershipApplications, getMembershipApplication, downloadMembershipApplicationDocument } from './services/membership-application.service'
|
||||
export type {
|
||||
MembershipApplicationFormState,
|
||||
MembershipApplicationSubmitResponse,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from './types/membership-application.types'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const membershipApplicationMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ClipboardList',
|
||||
route_name: 'list-membership-applications',
|
||||
title: 'Permohonan Keahlian',
|
||||
permission: 'lihat permohonan keahlian',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,854 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
|
||||
import type {
|
||||
MembershipApplicationFormState,
|
||||
MembershipApplicationHeirForm,
|
||||
MembershipApplicationReferenceForm,
|
||||
} from '../types/membership-application.types'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'Maklumat Peribadi' },
|
||||
{ id: 2, label: 'Hubungan & Alamat' },
|
||||
{ id: 3, label: 'Maklumat Pekerjaan' },
|
||||
{ id: 4, label: 'Maklumat Waris' },
|
||||
{ id: 5, label: 'Dokumen & Hantar' },
|
||||
] as const
|
||||
|
||||
const currentStep = ref(1)
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const applicationNumber = ref('')
|
||||
const errorMessage = ref('')
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Isteri', value: 'Isteri' },
|
||||
{ label: 'Suami', value: 'Suami' },
|
||||
{ label: 'Anak', value: 'Anak' },
|
||||
{ label: 'Bapa', value: 'Bapa' },
|
||||
{ label: 'Ibu', value: 'Ibu' },
|
||||
{ label: 'Orang Tua', value: 'Orang Tua' },
|
||||
{ label: 'Saudara', value: 'Saudara' },
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
|
||||
function createEmptyReference(): MembershipApplicationReferenceForm {
|
||||
return {
|
||||
ic_number: '',
|
||||
user_id: '',
|
||||
name: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyHeir(): MembershipApplicationHeirForm {
|
||||
return {
|
||||
name: '',
|
||||
ic_number: '',
|
||||
relationship: '',
|
||||
phone_number: '',
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<MembershipApplicationFormState>({
|
||||
applicant: {
|
||||
name: '',
|
||||
email: '',
|
||||
ic_number: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
gender: '',
|
||||
marriage_status: '',
|
||||
address: '',
|
||||
phone_number: '',
|
||||
office_number: '',
|
||||
postcode: '',
|
||||
employer_name: '',
|
||||
employer_address: '',
|
||||
current_position: '',
|
||||
start_work_date: '',
|
||||
stock_monthly_contribution: '',
|
||||
fee_monthly_contribution: '',
|
||||
},
|
||||
heirs: [createEmptyHeir()],
|
||||
references: {
|
||||
proposer: createEmptyReference(),
|
||||
supporter: createEmptyReference(),
|
||||
},
|
||||
documents: {
|
||||
ic_copy: null,
|
||||
photo: null,
|
||||
salary_slip: null,
|
||||
employer_letter: null,
|
||||
},
|
||||
})
|
||||
|
||||
const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applicant.gender))
|
||||
const marriageStatusInitial = computed(() =>
|
||||
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
|
||||
)
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
form.applicant.gender = labelToApiValue(GENDER_OPTIONS, details.value[0])
|
||||
delete fieldErrors['applicant.gender']
|
||||
}
|
||||
|
||||
function setMarriageStatusValue(details: { value: string[] }) {
|
||||
form.applicant.marriage_status = labelToApiValue(MARRIAGE_STATUS_OPTIONS, details.value[0])
|
||||
delete fieldErrors['applicant.marriage_status']
|
||||
}
|
||||
|
||||
function setHeirRelationshipValue(index: number, details: { value: string[] }) {
|
||||
const heir = form.heirs[index]
|
||||
if (!heir) return
|
||||
heir.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0])
|
||||
delete fieldErrors[`heirs.${index}.relationship`]
|
||||
}
|
||||
|
||||
const referenceLookupLoading = reactive({
|
||||
proposer: false,
|
||||
supporter: false,
|
||||
})
|
||||
|
||||
function clearReference(role: 'proposer' | 'supporter') {
|
||||
form.references[role].user_id = ''
|
||||
form.references[role].name = ''
|
||||
delete fieldErrors[`references.${role}_ic_number`]
|
||||
}
|
||||
|
||||
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
|
||||
clearReference(role)
|
||||
}
|
||||
|
||||
async function lookupReference(role: 'proposer' | 'supporter') {
|
||||
const reference = form.references[role]
|
||||
const icNumber = reference.ic_number.trim()
|
||||
const fieldKey = `references.${role}_ic_number`
|
||||
|
||||
delete fieldErrors[fieldKey]
|
||||
|
||||
if (!icNumber) {
|
||||
clearReference(role)
|
||||
return
|
||||
}
|
||||
|
||||
if (icNumber === form.applicant.ic_number.trim()) {
|
||||
clearReference(role)
|
||||
setError(fieldKey, 'Pencadang/penyokong tidak boleh sama dengan pemohon.')
|
||||
return
|
||||
}
|
||||
|
||||
const otherRole = role === 'proposer' ? 'supporter' : 'proposer'
|
||||
if (icNumber === form.references[otherRole].ic_number.trim()) {
|
||||
clearReference(role)
|
||||
setError(fieldKey, 'Pencadang dan penyokong mestilah ahli yang berbeza.')
|
||||
return
|
||||
}
|
||||
|
||||
referenceLookupLoading[role] = true
|
||||
|
||||
try {
|
||||
const response = await lookupMemberByIcNumber(icNumber)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
clearReference(role)
|
||||
setError(fieldKey, response.message || 'Ahli tidak dijumpai.')
|
||||
return
|
||||
}
|
||||
|
||||
reference.user_id = response.data.id
|
||||
reference.name = response.data.name
|
||||
reference.ic_number = response.data.ic_number
|
||||
} catch (error) {
|
||||
clearReference(role)
|
||||
setError(fieldKey, getApiErrorMessage(error, 'Gagal mencari ahli.'))
|
||||
} finally {
|
||||
referenceLookupLoading[role] = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateReference(role: 'proposer' | 'supporter', label: string): boolean {
|
||||
const reference = form.references[role]
|
||||
const fieldKey = `references.${role}_ic_number`
|
||||
|
||||
if (!reference.ic_number.trim()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!reference.user_id) {
|
||||
setError(fieldKey, `${label} tidak dijumpai. Sila semak no. KP.`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const stepTitle = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Maklumat Peribadi'
|
||||
case 2:
|
||||
return 'Hubungan & Alamat'
|
||||
case 3:
|
||||
return 'Maklumat Pekerjaan & Caruman'
|
||||
case 4:
|
||||
return 'Maklumat Waris'
|
||||
default:
|
||||
return 'Dokumen & Pengesahan'
|
||||
}
|
||||
})
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Sila isi maklumat peribadi anda dengan lengkap dan tepat.'
|
||||
case 2:
|
||||
return 'Masukkan maklumat hubungan dan alamat semasa.'
|
||||
case 3:
|
||||
return 'Masukkan maklumat pekerjaan dan caruman bulanan.'
|
||||
case 4:
|
||||
return 'Tambah sekurang-kurangnya satu waris.'
|
||||
default:
|
||||
return 'Muat naik dokumen sokongan dan semak maklumat sebelum hantar.'
|
||||
}
|
||||
})
|
||||
|
||||
function clearErrors() {
|
||||
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function setError(key: string, message: string) {
|
||||
fieldErrors[key] = message
|
||||
}
|
||||
|
||||
function validateStep(step: number): boolean {
|
||||
clearErrors()
|
||||
let valid = true
|
||||
|
||||
const requireField = (key: string, value: string | number | null | undefined, label: string) => {
|
||||
if (!String(value ?? '').trim()) {
|
||||
setError(key, `${label} diperlukan.`)
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
requireField('applicant.name', form.applicant.name, 'Nama penuh')
|
||||
requireField('applicant.email', form.applicant.email, 'Emel')
|
||||
requireField('applicant.ic_number', form.applicant.ic_number, 'No. kad pengenalan')
|
||||
requireField('applicant.birth_date', form.applicant.birth_date, 'Tarikh lahir')
|
||||
requireField('applicant.birth_place', form.applicant.birth_place, 'Tempat lahir')
|
||||
requireField('applicant.gender', form.applicant.gender, 'Jantina')
|
||||
requireField('applicant.marriage_status', form.applicant.marriage_status, 'Status perkahwinan')
|
||||
|
||||
if (form.applicant.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.applicant.email)) {
|
||||
setError('applicant.email', 'Emel tidak sah.')
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
requireField('applicant.address', form.applicant.address, 'Alamat')
|
||||
requireField('applicant.phone_number', form.applicant.phone_number, 'No. telefon')
|
||||
requireField('applicant.postcode', form.applicant.postcode, 'Poskod')
|
||||
}
|
||||
|
||||
if (step === 3) {
|
||||
requireField('applicant.employer_name', form.applicant.employer_name, 'Nama majikan')
|
||||
requireField('applicant.employer_address', form.applicant.employer_address, 'Alamat majikan')
|
||||
requireField('applicant.current_position', form.applicant.current_position, 'Jawatan semasa')
|
||||
requireField('applicant.start_work_date', form.applicant.start_work_date, 'Tarikh mula berkhidmat')
|
||||
requireField(
|
||||
'applicant.stock_monthly_contribution',
|
||||
form.applicant.stock_monthly_contribution,
|
||||
'Caruman saham bulanan',
|
||||
)
|
||||
requireField(
|
||||
'applicant.fee_monthly_contribution',
|
||||
form.applicant.fee_monthly_contribution,
|
||||
'Caruman yuran bulanan',
|
||||
)
|
||||
}
|
||||
|
||||
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}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (step === 5) {
|
||||
if (!validateReference('proposer', 'Pencadang')) {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!validateReference('supporter', 'Penyokong')) {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.documents.ic_copy) {
|
||||
setError('documents.ic_copy', 'Salinan kad pengenalan diperlukan.')
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (!validateStep(currentStep.value)) return
|
||||
if (currentStep.value < steps.length) {
|
||||
currentStep.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
function goPrevious() {
|
||||
clearErrors()
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function addHeir() {
|
||||
form.heirs.push(createEmptyHeir())
|
||||
}
|
||||
|
||||
function removeHeir(index: number) {
|
||||
if (form.heirs.length <= 1) return
|
||||
form.heirs.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleFileChange(
|
||||
key: keyof MembershipApplicationFormState['documents'],
|
||||
event: Event,
|
||||
) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0] ?? null
|
||||
|
||||
if (file && file.size > MAX_FILE_SIZE_BYTES) {
|
||||
form.documents[key] = null
|
||||
target.value = ''
|
||||
setError(`documents.${key}`, `Saiz fail melebihi had maksimum ${MAX_FILE_SIZE_MB}MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
form.documents[key] = file
|
||||
delete fieldErrors[`documents.${key}`]
|
||||
}
|
||||
|
||||
function applyServerErrors(errors: Record<string, string[]>) {
|
||||
Object.entries(errors).forEach(([key, messages]) => {
|
||||
if (messages[0]) {
|
||||
fieldErrors[key] = messages[0]
|
||||
}
|
||||
})
|
||||
|
||||
const stepByField: Record<string, number> = {
|
||||
applicant: 1,
|
||||
heirs: 4,
|
||||
references: 5,
|
||||
documents: 5,
|
||||
}
|
||||
|
||||
const firstKey = Object.keys(errors)[0]
|
||||
if (!firstKey) return
|
||||
|
||||
const prefix = firstKey.split('.')[0] ?? ''
|
||||
const step = stepByField[prefix]
|
||||
|
||||
if (step !== undefined) {
|
||||
currentStep.value = step
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateStep(5)) return
|
||||
|
||||
loading.value = true
|
||||
clearErrors()
|
||||
|
||||
try {
|
||||
const response = await submitMembershipApplication(form)
|
||||
applicationNumber.value = response.data.application_number
|
||||
submitted.value = true
|
||||
} catch (error) {
|
||||
const validationErrors = getApiValidationErrors(error)
|
||||
if (validationErrors) {
|
||||
applyServerErrors(validationErrors)
|
||||
errorMessage.value = 'Sila semak maklumat yang ditandakan.'
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(error, 'Gagal menghantar permohonan.')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stepButtonClass(stepId: number) {
|
||||
if (stepId === currentStep.value) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none'
|
||||
}
|
||||
|
||||
if (stepId < currentStep.value) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
return 'bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full'
|
||||
}
|
||||
|
||||
function stepLabelClass(stepId: number) {
|
||||
return stepId === currentStep.value
|
||||
? 'text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="border-b border-foreground/10 bg-primary/5">
|
||||
<div class="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<img :src="illustrationUrl" alt="MyKOPKB" class="h-10 w-auto" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold">Permohonan Keahlian</div>
|
||||
<div class="text-sm opacity-70">Borang permohonan ahli koperasi</div>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/login">
|
||||
<Button look="outline" size="sm">Log Masuk</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submitted" class="container mx-auto px-5 py-16 sm:px-8">
|
||||
<Box class="mx-auto max-w-2xl py-12 text-center">
|
||||
<div class="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<Lucide icon="CircleCheck" class="size-8" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold">Permohonan Berjaya Dihantar</h2>
|
||||
<p class="mt-3 opacity-70">
|
||||
Terima kasih. Permohonan keahlian anda telah diterima.
|
||||
</p>
|
||||
<div class="mt-6 rounded-lg border border-foreground/10 bg-foreground/5 px-6 py-4">
|
||||
<div class="text-sm opacity-70">No. Permohonan</div>
|
||||
<div class="mt-1 text-xl font-semibold text-primary">{{ applicationNumber }}</div>
|
||||
</div>
|
||||
<p class="mt-6 text-sm opacity-70">
|
||||
E-mel permohonan anda telah dihantar. Anda akan menerima emel
|
||||
apabila keputusan permohonan tersedia.
|
||||
</p>
|
||||
<RouterLink to="/login" class="mt-8 inline-block">
|
||||
<Button>Kembali ke Log Masuk</Button>
|
||||
</RouterLink>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto px-5 py-8 sm:px-8">
|
||||
<Box class="py-10 sm:py-16">
|
||||
<div
|
||||
class="before:bg-foreground/10 relative flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-10 lg:flex-row before:lg:block">
|
||||
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'default' : 'ghost'">
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
<div :class="stepLabelClass(step.id)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 border-t border-foreground/10 px-5 pt-10 sm:px-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="text-lg font-medium">{{ stepTitle }}</div>
|
||||
<div class="mt-2 opacity-70">{{ stepDescription }}</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-8 grid grid-cols-12 gap-4 gap-y-5">
|
||||
<template v-if="currentStep === 1">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="name">Nama Penuh</FieldLabel>
|
||||
<Input id="name" v-model="form.applicant.name" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.name']">{{ fieldErrors['applicant.name'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="email">Emel</FieldLabel>
|
||||
<Input id="email" v-model="form.applicant.email" type="email" />
|
||||
<FieldError v-if="fieldErrors['applicant.email']">{{ fieldErrors['applicant.email'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="birth_date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="birth_date" v-model="form.applicant.birth_date" type="date" />
|
||||
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="birth_place">Tempat Lahir</FieldLabel>
|
||||
<Input id="birth_place" v-model="form.applicant.birth_place" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot :key="`gender-${form.applicant.gender}`" class="w-full" :collection="genderCollection"
|
||||
:default-value="genderInitial" @value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.gender']">
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors['applicant.gender']">{{ fieldErrors['applicant.gender'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot :key="`marriage-status-${form.applicant.marriage_status}`" class="w-full"
|
||||
:collection="marriageStatusCollection" :default-value="marriageStatusInitial"
|
||||
@value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.marriage_status']">
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors['applicant.marriage_status']">
|
||||
{{ fieldErrors['applicant.marriage_status'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel for="address">Alamat</FieldLabel>
|
||||
<Textarea id="address" v-model="form.applicant.address" rows="3" />
|
||||
<FieldError v-if="fieldErrors['applicant.address']">{{ fieldErrors['applicant.address'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="phone_number">No. Telefon</FieldLabel>
|
||||
<Input id="phone_number" v-model="form.applicant.phone_number" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.phone_number']">
|
||||
{{ fieldErrors['applicant.phone_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="office_number">No. Pejabat (Pilihan)</FieldLabel>
|
||||
<Input id="office_number" v-model="form.applicant.office_number" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.office_number']">
|
||||
{{ fieldErrors['applicant.office_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="postcode">Poskod</FieldLabel>
|
||||
<Input id="postcode" v-model="form.applicant.postcode" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.postcode']">{{ fieldErrors['applicant.postcode'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</template>
|
||||
|
||||
<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" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_name']">
|
||||
{{ fieldErrors['applicant.employer_name'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
|
||||
<Input id="current_position" v-model="form.applicant.current_position" type="text" />
|
||||
<FieldError v-if="fieldErrors['applicant.current_position']">
|
||||
{{ fieldErrors['applicant.current_position'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
|
||||
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">
|
||||
{{ fieldErrors['applicant.employer_address'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
|
||||
<Input id="start_work_date" v-model="form.applicant.start_work_date" type="date" />
|
||||
<FieldError v-if="fieldErrors['applicant.start_work_date']">
|
||||
{{ fieldErrors['applicant.start_work_date'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
|
||||
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
|
||||
min="0" step="0.01" />
|
||||
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">
|
||||
{{ fieldErrors['applicant.stock_monthly_contribution'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="fee_monthly_contribution">Caruman Yuran (RM)</FieldLabel>
|
||||
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
|
||||
min="0" step="0.01" />
|
||||
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">
|
||||
{{ fieldErrors['applicant.fee_monthly_contribution'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 4">
|
||||
<div class="col-span-12 space-y-6">
|
||||
<div v-for="(heir, index) in form.heirs" :key="index"
|
||||
class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="font-medium">Waris {{ index + 1 }}</div>
|
||||
<Button v-if="form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel>
|
||||
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">
|
||||
{{ fieldErrors[`heirs.${index}.name`] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
|
||||
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
|
||||
{{ fieldErrors[`heirs.${index}.ic_number`] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<SelectRoot :key="`heir-relationship-${index}-${heir.relationship}`" class="w-full"
|
||||
:collection="relationshipCollection"
|
||||
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)"
|
||||
@value-change="(details) => setHeirRelationshipValue(index, details)">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors[`heirs.${index}.relationship`]">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">
|
||||
{{ fieldErrors[`heirs.${index}.relationship`] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-phone-${index}`">No. Telefon</FieldLabel>
|
||||
<Input :id="`heir-phone-${index}`" v-model="heir.phone_number" type="text" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">
|
||||
{{ fieldErrors[`heirs.${index}.phone_number`] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" look="outline" @click="addHeir">
|
||||
<Lucide icon="Plus" class="mr-2 size-4" />
|
||||
Tambah Waris
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="col-span-12 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
|
||||
<div class="mb-1 font-medium">Pencadang & Penyokong (Pilihan)</div>
|
||||
<p class="text-sm opacity-70">
|
||||
Masukkan no. KP ahli sedia ada. Sistem akan mengesahkan dan mengisi nama secara automatik.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
|
||||
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
|
||||
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
|
||||
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
|
||||
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.proposer.name }}
|
||||
</p>
|
||||
<FieldError v-if="fieldErrors['references.proposer_ic_number']">
|
||||
{{ fieldErrors['references.proposer_ic_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
|
||||
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
|
||||
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
|
||||
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
|
||||
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.supporter.name }}
|
||||
</p>
|
||||
<FieldError v-if="fieldErrors['references.supporter_ic_number']">
|
||||
{{ fieldErrors['references.supporter_ic_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="col-span-12 rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm opacity-80">
|
||||
Saiz maksimum setiap fail: <strong>{{ MAX_FILE_SIZE_MB }}MB</strong>.
|
||||
Format yang dibenarkan: PDF, JPG, JPEG atau PNG.
|
||||
</div>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="ic_copy">Salinan Kad Pengenalan *</FieldLabel>
|
||||
<Input id="ic_copy" type="file" accept=".pdf,.jpg,.jpeg,.png"
|
||||
@change="handleFileChange('ic_copy', $event)" />
|
||||
<FieldError v-if="fieldErrors['documents.ic_copy']">{{ fieldErrors['documents.ic_copy'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="photo">Gambar Passport (Pilihan)</FieldLabel>
|
||||
<Input id="photo" type="file" accept=".jpg,.jpeg,.png" @change="handleFileChange('photo', $event)" />
|
||||
<FieldError v-if="fieldErrors['documents.photo']">{{ fieldErrors['documents.photo'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="salary_slip">Slip Gaji (Pilihan)</FieldLabel>
|
||||
<Input id="salary_slip" type="file" accept=".pdf,.jpg,.jpeg,.png"
|
||||
@change="handleFileChange('salary_slip', $event)" />
|
||||
<FieldError v-if="fieldErrors['documents.salary_slip']">{{ fieldErrors['documents.salary_slip'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="employer_letter">Surat Pengesahan Majikan (Pilihan)</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']
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
|
||||
<div class="mb-3 font-medium">Semakan Ringkas</div>
|
||||
<div class="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div><span class="opacity-70">Nama:</span> {{ form.applicant.name }}</div>
|
||||
<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">Pencadang:</span>
|
||||
{{ form.references.proposer.name || '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Penyokong:</span>
|
||||
{{ form.references.supporter.name || '-' }}
|
||||
</div>
|
||||
<div><span class="opacity-70">Dokumen IC:</span> {{ form.documents.ic_copy?.name ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="col-span-12 mt-5 flex items-center justify-center sm:justify-end">
|
||||
<Button v-if="currentStep > 1" type="button" look="outline" class="w-32" :disabled="loading"
|
||||
@click="goPrevious">
|
||||
Sebelum
|
||||
</Button>
|
||||
<Button v-if="currentStep < steps.length" type="button" class="ml-2 w-32" @click="goNext">
|
||||
Seterusnya
|
||||
</Button>
|
||||
<Button v-else type="button" class="ml-2 w-40" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Permohonan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,935 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
completeMembershipApplication,
|
||||
downloadMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
getMembershipApplication,
|
||||
submitBoardReview,
|
||||
submitManagementReview,
|
||||
} from '../services/membership-application.service'
|
||||
import type {
|
||||
BoardReviewDecision,
|
||||
ManagementReviewDecision,
|
||||
MembershipApplicationDetail,
|
||||
MembershipApplicationDocumentDetail,
|
||||
MembershipApplicationReferenceDetail,
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
|
||||
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
ic_copy: 'Salinan Kad Pengenalan',
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const applicationId = computed(() => String(route.params.id ?? ''))
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const application = ref<MembershipApplicationDetail | null>(null)
|
||||
const managementRemarks = ref('')
|
||||
const boardRemarks = ref('')
|
||||
const reviewSubmitting = ref(false)
|
||||
const completeSubmitting = ref(false)
|
||||
const confirmDialogOpen = ref(false)
|
||||
const pendingAction = ref<
|
||||
| { type: 'management'; decision: ManagementReviewDecision }
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' }
|
||||
| null
|
||||
>(null)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
}
|
||||
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return { currentStep: 2, failed: false, failedStep: null as number | null }
|
||||
case 'MANAGEMENT_REJECTED':
|
||||
return { currentStep: 2, failed: true, failedStep: 2 }
|
||||
case 'PENDING_BOARD':
|
||||
return { currentStep: 3, failed: false, failedStep: null }
|
||||
case 'PENDING_NOTIFICATION':
|
||||
return { currentStep: 4, failed: false, failedStep: null }
|
||||
case 'COMPLETED':
|
||||
return { currentStep: 6, failed: false, failedStep: null }
|
||||
default:
|
||||
return { currentStep: 1, failed: false, failedStep: null }
|
||||
}
|
||||
}
|
||||
|
||||
const workflowProgress = computed(() =>
|
||||
application.value ? getWorkflowProgress(application.value.status) : null,
|
||||
)
|
||||
|
||||
const showManagementReview = computed(
|
||||
() =>
|
||||
hasPermission('semak permohonan keahlian pentadbiran') &&
|
||||
application.value?.status === 'SUBMITTED',
|
||||
)
|
||||
|
||||
const showBoardReview = computed(
|
||||
() =>
|
||||
hasPermission('semak permohonan keahlian lembaga') &&
|
||||
application.value?.status === 'PENDING_BOARD',
|
||||
)
|
||||
|
||||
const showCompleteAction = computed(
|
||||
() =>
|
||||
hasPermission('selesaikan permohonan keahlian') &&
|
||||
application.value?.status === 'PENDING_NOTIFICATION',
|
||||
)
|
||||
|
||||
const confirmDialogTitle = computed(() => {
|
||||
if (!pendingAction.value) return 'Sahkan Tindakan'
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Luluskan Permohonan?'
|
||||
: 'Tolak Permohonan?'
|
||||
}
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Luluskan Semakan Lembaga?'
|
||||
: 'Gagalkan Semakan Lembaga?'
|
||||
}
|
||||
|
||||
return 'Selesaikan Permohonan?'
|
||||
})
|
||||
|
||||
const confirmDialogDescription = computed(() => {
|
||||
if (!pendingAction.value) return ''
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Permohonan akan dihantar ke semakan lembaga.'
|
||||
: '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.'
|
||||
}
|
||||
|
||||
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
})
|
||||
|
||||
function workflowStepButtonClass(stepId: number) {
|
||||
const progress = workflowProgress.value
|
||||
if (!progress) return 'mx-2 size-12 rounded-full shadow-none bg-background border border-foreground/15'
|
||||
|
||||
if (progress.failed && stepId === progress.failedStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-danger text-danger-foreground'
|
||||
}
|
||||
|
||||
if (stepId < progress.currentStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
if (stepId === progress.currentStep && application.value?.status !== 'COMPLETED') {
|
||||
return 'mx-2 size-12 rounded-full shadow-none'
|
||||
}
|
||||
|
||||
if (application.value?.status === 'COMPLETED' || stepId < progress.currentStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
return 'bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full'
|
||||
}
|
||||
|
||||
function workflowStepLabelClass(stepId: number) {
|
||||
const progress = workflowProgress.value
|
||||
const isCurrent =
|
||||
progress &&
|
||||
stepId === progress.currentStep &&
|
||||
application.value?.status !== 'COMPLETED' &&
|
||||
!progress.failed
|
||||
|
||||
return isCurrent
|
||||
? 'text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
}
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
|
||||
const sortedReviews = computed(() => {
|
||||
if (!application.value?.reviews.length) return []
|
||||
|
||||
return [...application.value.reviews].sort((left, right) => {
|
||||
const leftTime = left.reviewed_at ? new Date(left.reviewed_at).getTime() : 0
|
||||
const rightTime = right.reviewed_at ? new Date(right.reviewed_at).getTime() : 0
|
||||
return leftTime - rightTime
|
||||
})
|
||||
})
|
||||
|
||||
function displayValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY')
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY HH:mm')
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return String(value)
|
||||
return `RM ${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function documentLabel(type: string, name: string): string {
|
||||
return DOCUMENT_TYPE_LABELS[type] ?? name
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType: string | null | undefined): boolean {
|
||||
return !!mimeType?.startsWith('image/')
|
||||
}
|
||||
|
||||
function isPdfDocument(document: MembershipApplicationDocumentDetail): boolean {
|
||||
return document.mime_type === 'application/pdf' || document.name.toLowerCase().endsWith('.pdf')
|
||||
}
|
||||
|
||||
const isPreviewImage = computed(() => isImageMimeType(previewDocument.value?.mime_type))
|
||||
const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previewDocument.value) : false))
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewUrl.value) {
|
||||
window.URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewOpenChange(open: boolean) {
|
||||
previewOpen.value = open
|
||||
|
||||
if (!open) {
|
||||
revokePreviewUrl()
|
||||
previewDocument.value = null
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationReferenceDetail | null {
|
||||
return application.value?.references.find((reference) => reference.reference_type === type) ?? null
|
||||
}
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
return stage
|
||||
}
|
||||
|
||||
function reviewDecisionLabel(decision: string | null, stage: string): string {
|
||||
if (!decision) return '-'
|
||||
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'Diluluskan'
|
||||
if (decision === 'REJECTED') return 'Ditolak'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'Lulus'
|
||||
if (decision === 'FAIL') return 'Gagal'
|
||||
}
|
||||
|
||||
return decision
|
||||
}
|
||||
|
||||
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'success'
|
||||
if (decision === 'REJECTED') return 'danger'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'success'
|
||||
if (decision === 'FAIL') return 'danger'
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
|
||||
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
|
||||
|
||||
if (variant === 'success') return 'border-success bg-success'
|
||||
if (variant === 'danger') return 'border-danger bg-danger'
|
||||
return 'border-primary bg-primary'
|
||||
}
|
||||
|
||||
async function fetchApplication() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await getMembershipApplication(applicationId.value)
|
||||
application.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran permohonan.')
|
||||
application.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmAction(
|
||||
action:
|
||||
| { type: 'management'; decision: ManagementReviewDecision }
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' },
|
||||
) {
|
||||
pendingAction.value = action
|
||||
confirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeConfirmDialog() {
|
||||
confirmDialogOpen.value = false
|
||||
pendingAction.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
if (!pendingAction.value || !application.value) return
|
||||
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
let response
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
response = await submitManagementReview(applicationId.value, {
|
||||
decision: pendingAction.value.decision,
|
||||
remarks: managementRemarks.value.trim() || null,
|
||||
})
|
||||
managementRemarks.value = ''
|
||||
} else if (pendingAction.value.type === 'board') {
|
||||
response = await submitBoardReview(applicationId.value, {
|
||||
decision: pendingAction.value.decision,
|
||||
remarks: boardRemarks.value.trim() || null,
|
||||
})
|
||||
boardRemarks.value = ''
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value)
|
||||
}
|
||||
|
||||
application.value = response.data
|
||||
successMessage.value = response.message
|
||||
closeConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memproses tindakan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.remarks?.[0]) {
|
||||
error.value = validationErrors.remarks[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadMembershipApplicationDocument(
|
||||
application.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
revokePreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchMembershipApplicationDocument(
|
||||
application.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
previewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
handlePreviewOpenChange(false)
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan dokumen.')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplication()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokePreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Permohonan Keahlian</h2>
|
||||
<p v-if="application" class="mt-1 text-sm opacity-70">
|
||||
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button"
|
||||
@click="router.push({ name: 'list-membership-applications' })">
|
||||
Kembali
|
||||
</Button>
|
||||
<Button v-if="hasPermission('kemaskini permohonan keahlian') && application?.status !== 'COMPLETED'" type="button"
|
||||
@click="router.push({ name: 'edit-membership-application', params: { id: applicationId } })">
|
||||
<Pencil class="mr-2 size-4" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan butiran permohonan...</div>
|
||||
|
||||
<template v-else-if="application">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm opacity-70">No. Permohonan</div>
|
||||
<div class="text-xl font-semibold">{{ application.application_number }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
|
||||
{{ statusLabel(application.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="application.board_result"
|
||||
:variant="application.board_result === 'PASS' ? 'success' : 'danger'">
|
||||
Lembaga: {{ application.board_result === 'PASS' ? 'Lulus' : 'Gagal' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Hantar:</span>
|
||||
{{ formatDateTime(application.submitted_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Selesai:</span>
|
||||
{{ formatDateTime(application.completed_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Emel Pemohon:</span>
|
||||
<span class="lowercase">{{ displayValue(application.applicant?.email) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(application.applicant?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="py-8 sm:py-10">
|
||||
<div class="px-5 sm:px-8">
|
||||
<div class="text-sm font-medium opacity-70">Status Permohonan</div>
|
||||
</div>
|
||||
<div
|
||||
class="before:bg-foreground/10 relative mt-4 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-8 lg:flex-row before:lg:block">
|
||||
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
|
||||
class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button type="button" :class="workflowStepButtonClass(step.id)"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
|
||||
disabled>
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
<div :class="workflowStepLabelClass(step.id)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showManagementReview" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Semakan Pentadbiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Luluskan atau tolak permohonan pada peringkat pentadbiran.
|
||||
</p>
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="management-remarks">Catatan</FieldLabel>
|
||||
<Textarea id="management-remarks" v-model="managementRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
|
||||
:disabled="reviewSubmitting" />
|
||||
</Field>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
|
||||
Luluskan
|
||||
</Button>
|
||||
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
|
||||
Tolak
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showBoardReview" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Semakan Lembaga</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Luluskan atau gagalkan permohonan pada peringkat lembaga.
|
||||
</p>
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="board-remarks">Catatan</FieldLabel>
|
||||
<Textarea id="board-remarks" v-model="boardRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
|
||||
:disabled="reviewSubmitting" />
|
||||
</Field>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'board', decision: 'PASS' })">
|
||||
Lulus
|
||||
</Button>
|
||||
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'board', decision: 'FAIL' })">
|
||||
Gagal
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<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>.
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsRoot defaultValue="personal" class="w-full">
|
||||
<Box raised="single" class="w-full p-0">
|
||||
<div class="w-full px-5 py-4">
|
||||
<TabsList class="mb-0 flex w-full">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="personal">
|
||||
Maklumat Peribadi
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="contact">
|
||||
Hubungan & Alamat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="employment">
|
||||
Pekerjaan & Caruman
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="heirs">
|
||||
Waris
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="references">
|
||||
Pencadang & Penyokong
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="documents">
|
||||
Dokumen
|
||||
</TabsTrigger>
|
||||
<TabsTrigger v-if="application.reviews.length"
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="reviews">
|
||||
Sejarah Semakan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsContent value="personal" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama Penuh</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Emel</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.email)" type="text" disabled class="lowercase" />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Kad Pengenalan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.ic_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Tarikh Lahir</FieldLabel>
|
||||
<Input :model-value="formatDate(applicant.birth_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Tempat Lahir</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.birth_place)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.gender)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.marriage_status)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contact" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel>Alamat</FieldLabel>
|
||||
<Textarea :model-value="displayValue(applicant.address)" rows="3" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>No. Telefon</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.phone_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>No. Pejabat</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.office_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Poskod</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.postcode)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="employment" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama Majikan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.employer_name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jawatan Semasa</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.current_position)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel>Alamat Majikan</FieldLabel>
|
||||
<Textarea :model-value="displayValue(applicant.employer_address)" rows="3" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Tarikh Mula Berkhidmat</FieldLabel>
|
||||
<Input :model-value="formatDate(applicant.start_work_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Caruman Saham (RM)</FieldLabel>
|
||||
<Input :model-value="formatCurrency(applicant.stock_monthly_contribution)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Caruman Yuran (RM)</FieldLabel>
|
||||
<Input :model-value="formatCurrency(applicant.fee_monthly_contribution)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="heirs" class="mt-6">
|
||||
<div v-if="!application.heirs.length" class="opacity-70">Tiada maklumat waris.</div>
|
||||
<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="grid grid-cols-12 gap-4">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Kad Pengenalan</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.ic_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.relationship)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Telefon</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.phone_number)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="references" class="mt-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="font-medium">Pencadang</div>
|
||||
<div class="mt-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Nama:</span>
|
||||
{{ displayValue(getReference('PROPOSER')?.member?.name) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(getReference('PROPOSER')?.member?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="font-medium">Penyokong</div>
|
||||
<div class="mt-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Nama:</span>
|
||||
{{ displayValue(getReference('SUPPORTER')?.member?.name) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(getReference('SUPPORTER')?.member?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" class="mt-6">
|
||||
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="document in application.documents" :key="document.id"
|
||||
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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
<div class="relative ms-3 ps-8">
|
||||
<div v-for="(review, index) in sortedReviews" :key="review.id" class="relative pb-8 last:pb-0">
|
||||
<span class="absolute -inset-s-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
|
||||
:class="reviewTimelineDotClass(review)" />
|
||||
<span v-if="index < sortedReviews.length - 1"
|
||||
class="absolute -inset-s-3.5 top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15" />
|
||||
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="font-medium">{{ reviewStageLabel(review.stage) }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)" class="whitespace-nowrap">
|
||||
{{ reviewDecisionLabel(review.decision, review.stage) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Disemak oleh:</span>
|
||||
{{ displayValue(review.reviewer?.name) }}
|
||||
</div>
|
||||
<div v-if="review.remarks">
|
||||
<span class="opacity-70">Catatan:</span>
|
||||
{{ review.remarks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="confirmDialogOpen"
|
||||
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
|
||||
<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>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-28" type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="confirmPendingAction">
|
||||
{{ reviewSubmitting || completeSubmitting ? 'Memproses...' : 'Sahkan' }}
|
||||
</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"
|
||||
:aria-label="previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton dokumen'">
|
||||
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
|
||||
@click="handlePreviewOpenChange(false)" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="previewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && isPreviewImage" class="flex justify-center">
|
||||
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
|
||||
</div>
|
||||
|
||||
<iframe v-else-if="previewUrl && isPreviewPdf" :src="previewUrl"
|
||||
class="block w-full rounded-lg border border-foreground/10" style="height: min(70vh, 720px)"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'" />
|
||||
|
||||
<div v-else-if="previewUrl" class="py-12 text-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="handlePreviewOpenChange(false)">
|
||||
Tutup
|
||||
</Button>
|
||||
<Button v-if="previewDocument" type="button" look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id" @click="handleDownloadDocument(previewDocument)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,502 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import axios from 'axios'
|
||||
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { batchCompleteMembershipApplications } from '../services/membership-application.service'
|
||||
import type {
|
||||
BatchCompleteFailedItem,
|
||||
BatchCompleteResponse,
|
||||
MembershipApplicationBoardResult,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
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: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
|
||||
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
|
||||
{ label: 'Selesai', value: 'COMPLETED' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
const allOption = options.find((item) => item.value === '')
|
||||
return allOption ? [allOption.label] : []
|
||||
}
|
||||
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
const statusFilterCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const {
|
||||
applications,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchApplications,
|
||||
} = useMembershipApplicationList()
|
||||
|
||||
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
||||
const selectedIds = ref<string[]>([])
|
||||
const batchSubmitting = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const batchSuccessMessage = ref<string | null>(null)
|
||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||
|
||||
const selectableApplications = computed(() =>
|
||||
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
|
||||
)
|
||||
|
||||
const allSelectableSelected = computed(() => {
|
||||
const eligible = selectableApplications.value
|
||||
return eligible.length > 0 && eligible.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
watch(applications, () => {
|
||||
selectedIds.value = selectedIds.value.filter((id) =>
|
||||
applications.value.some((item) => item.id === id && item.status === 'PENDING_NOTIFICATION'),
|
||||
)
|
||||
})
|
||||
|
||||
function isSelectable(item: MembershipApplicationListItem): boolean {
|
||||
return item.status === 'PENDING_NOTIFICATION'
|
||||
}
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedIds.value.includes(id)
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
return
|
||||
}
|
||||
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
|
||||
function toggleSelectAllOnPage(checked: boolean) {
|
||||
if (!checked) {
|
||||
const pageIds = new Set(selectableApplications.value.map((item) => item.id))
|
||||
selectedIds.value = selectedIds.value.filter((id) => !pageIds.has(id))
|
||||
return
|
||||
}
|
||||
|
||||
const merged = new Set([
|
||||
...selectedIds.value,
|
||||
...selectableApplications.value.map((item) => item.id),
|
||||
])
|
||||
selectedIds.value = [...merged]
|
||||
}
|
||||
|
||||
function openBatchConfirm() {
|
||||
if (!selectedIds.value.length) return
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatchComplete() {
|
||||
if (!selectedIds.value.length || batchSubmitting.value) return
|
||||
|
||||
batchSubmitting.value = true
|
||||
batchSuccessMessage.value = null
|
||||
batchFailedItems.value = []
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value)
|
||||
|
||||
if (response.success) {
|
||||
batchSuccessMessage.value = response.message
|
||||
batchFailedItems.value = response.data.failed
|
||||
selectedIds.value = []
|
||||
batchConfirmOpen.value = false
|
||||
await fetchApplications(page.value)
|
||||
} else {
|
||||
error.value = response.message
|
||||
batchFailedItems.value = response.data.failed
|
||||
}
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.data) {
|
||||
const responseData = err.response.data as BatchCompleteResponse
|
||||
batchFailedItems.value = responseData.data?.failed ?? []
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
}
|
||||
} finally {
|
||||
batchSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setStatusFilterValue(details: { value: string[] }) {
|
||||
statusFilter.value = labelToApiValue(STATUS_FILTER_OPTIONS, details.value[0])
|
||||
}
|
||||
|
||||
const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
}
|
||||
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function boardResultLabel(result: MembershipApplicationBoardResult | null): string {
|
||||
if (result === 'PASS') return 'Lulus'
|
||||
if (result === 'FAIL') return 'Gagal'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function boardResultBadgeVariant(result: MembershipApplicationBoardResult | null) {
|
||||
if (result === 'PASS') return 'success'
|
||||
if (result === 'FAIL') return 'danger'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function formatSubmittedAt(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY HH:mm')
|
||||
}
|
||||
|
||||
function goToApplicationDetail(id: string) {
|
||||
router.push({ name: 'view-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
function goToApplicationEdit(id: string) {
|
||||
router.push({ name: 'edit-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
const headers = computed<TableHeader[]>(() => {
|
||||
const base: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
|
||||
{
|
||||
title: 'Nama Pemohon',
|
||||
key: 'applicant_name',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.name ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Emel',
|
||||
key: 'applicant_email',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.email ?? '',
|
||||
},
|
||||
{
|
||||
title: 'No. IC',
|
||||
key: 'applicant_ic_number',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.ic_number ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
sortable: true,
|
||||
exportValue: (item) => statusLabel(item.status),
|
||||
},
|
||||
{
|
||||
title: 'Keputusan Lembaga',
|
||||
key: 'board_result',
|
||||
sortable: false,
|
||||
exportValue: (item) => boardResultLabel(item.board_result),
|
||||
},
|
||||
{
|
||||
title: 'Tarikh Hantar',
|
||||
key: 'submitted_at',
|
||||
sortable: true,
|
||||
exportValue: (item) => formatSubmittedAt(item.submitted_at),
|
||||
},
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
if (canBatchComplete.value) {
|
||||
return [{ title: '', key: 'select', sortable: false, width: 48 }, ...base]
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Senarai Permohonan Keahlian</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ batchSuccessMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="batchSuccessMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="batchFailedItems.length" variant="warning">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Sebahagian Permohonan Gagal</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul class="mt-2 list-disc space-y-1 ps-4 text-left">
|
||||
<li v-for="item in batchFailedItems" :key="item.id">
|
||||
{{ item.application_number ?? item.id }}: {{ item.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
<AlertCloseTrigger @click="batchFailedItems = []" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="applications"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="permohonan-keahlian"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Cari no. permohonan, nama, emel, IC..."
|
||||
class="w-full pl-9"
|
||||
aria-label="Cari permohonan keahlian"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectRoot
|
||||
class="w-full sm:w-56"
|
||||
:collection="statusFilterCollection"
|
||||
:default-value="statusFilterInitial"
|
||||
@value-change="setStatusFilterValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusFilterCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<template v-if="canBatchComplete">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
:disabled="!selectableApplications.length || loading"
|
||||
@click="toggleSelectAllOnPage(!allSelectableSelected)"
|
||||
>
|
||||
{{ allSelectableSelected ? 'Nyahpilih Halaman' : 'Pilih Halaman' }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="!selectedIds.length || loading || batchSubmitting"
|
||||
@click="openBatchConfirm"
|
||||
>
|
||||
Selesaikan Terpilih ({{ selectedIds.length }})
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="canBatchComplete" #item.select="{ item }">
|
||||
<CheckboxRoot
|
||||
v-if="isSelectable(item as MembershipApplicationListItem)"
|
||||
:checked="isSelected((item as MembershipApplicationListItem).id)"
|
||||
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<template #item.applicant_name="{ item }">
|
||||
<span class="font-medium">{{ (item as MembershipApplicationListItem).applicant?.name ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.applicant_email="{ item }">
|
||||
<span class="lowercase">{{ (item as MembershipApplicationListItem).applicant?.email ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.applicant_ic_number="{ item }">
|
||||
{{ (item as MembershipApplicationListItem).applicant?.ic_number ?? '-' }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ statusLabel((item as MembershipApplicationListItem).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.board_result="{ item }">
|
||||
<Badge
|
||||
v-if="(item as MembershipApplicationListItem).board_result"
|
||||
:variant="boardResultBadgeVariant((item as MembershipApplicationListItem).board_result)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ boardResultLabel((item as MembershipApplicationListItem).board_result) }}
|
||||
</Badge>
|
||||
<span v-else class="opacity-50">-</span>
|
||||
</template>
|
||||
|
||||
<template #item.submitted_at="{ item }">
|
||||
{{ formatSubmittedAt((item as MembershipApplicationListItem).submitted_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="hasPermission('lihat permohonan keahlian')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat butiran permohonan"
|
||||
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasPermission('kemaskini permohonan keahlian')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-blue-600 text-white"
|
||||
title="Kemaskini permohonan"
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<DialogRoot
|
||||
:open="batchConfirmOpen"
|
||||
@openChange="(details) => { batchConfirmOpen = details.open }"
|
||||
>
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ selectedIds.length }} permohonan akan diselesaikan dan e-mel keputusan dihantar.
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="batchSubmitting"
|
||||
@click="confirmBatchComplete"
|
||||
>
|
||||
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const membershipApplicationPublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/apply-membership',
|
||||
name: 'membership-application-apply',
|
||||
component: () => import('./pages/MembershipApplication.vue'),
|
||||
meta: { public: true, module: 'membership-application' },
|
||||
},
|
||||
]
|
||||
|
||||
export const membershipApplicationLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'membership-applications',
|
||||
name: 'list-membership-applications',
|
||||
component: () => import('./pages/MembershipApplicationList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Permohonan Keahlian',
|
||||
module: 'membership-application',
|
||||
permission: 'lihat permohonan keahlian',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'membership-applications/:id',
|
||||
name: 'view-membership-application',
|
||||
component: () => import('./pages/MembershipApplicationDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Permohonan Keahlian',
|
||||
module: 'membership-application',
|
||||
permission: 'lihat permohonan keahlian',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'membership-applications/:id/edit',
|
||||
name: 'edit-membership-application',
|
||||
component: () => import('./pages/MembershipApplicationEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Permohonan Keahlian',
|
||||
module: 'membership-application',
|
||||
permission: 'kemaskini permohonan keahlian',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,251 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
DocumentUploadType,
|
||||
ListMembershipApplicationsParams,
|
||||
MembershipApplicationApiResponse,
|
||||
MembershipApplicationFormState,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationReviewPayload,
|
||||
MembershipApplicationReviewResponse,
|
||||
MembershipApplicationSubmitResponse,
|
||||
BatchCompleteResponse,
|
||||
MembershipApplicationUpdateResponse,
|
||||
MemberLookupResponse,
|
||||
UpdateMembershipApplicationPayload,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
function appendFormData(formData: FormData, key: string, value: string | number | File) {
|
||||
formData.append(key, String(value))
|
||||
}
|
||||
|
||||
// Build form data for membership application
|
||||
export function buildMembershipApplicationFormData(
|
||||
form: MembershipApplicationFormState,
|
||||
): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
Object.entries(form.applicant).forEach(([key, value]) => {
|
||||
if (value !== '') {
|
||||
appendFormData(formData, `applicant[${key}]`, value)
|
||||
}
|
||||
})
|
||||
|
||||
form.heirs.forEach((heir, index) => {
|
||||
Object.entries(heir).forEach(([key, value]) => {
|
||||
if (value !== '') {
|
||||
appendFormData(formData, `heirs[${index}][${key}]`, value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (form.references.proposer.ic_number.trim()) {
|
||||
appendFormData(formData, 'references[proposer_ic_number]', form.references.proposer.ic_number.trim())
|
||||
}
|
||||
|
||||
if (form.references.supporter.ic_number.trim()) {
|
||||
appendFormData(formData, 'references[supporter_ic_number]', form.references.supporter.ic_number.trim())
|
||||
}
|
||||
|
||||
Object.entries(form.documents).forEach(([key, file]) => {
|
||||
if (file instanceof File) {
|
||||
formData.append(`documents[${key}]`, file)
|
||||
}
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
// Submit membership application
|
||||
export async function submitMembershipApplication(
|
||||
form: MembershipApplicationFormState,
|
||||
): Promise<MembershipApplicationSubmitResponse> {
|
||||
const formData = buildMembershipApplicationFormData(form)
|
||||
|
||||
const { data } = await api.post<MembershipApplicationSubmitResponse>(
|
||||
'/v1/public/membership-applications',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Lookup member by IC number
|
||||
export async function lookupMemberByIcNumber(icNumber: string): Promise<MemberLookupResponse> {
|
||||
const { data } = await api.get<MemberLookupResponse>('/v1/public/membership-applications/member-lookup', {
|
||||
params: { ic_number: icNumber.trim() },
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// List membership applications
|
||||
export async function listMembershipApplications(
|
||||
params: ListMembershipApplicationsParams,
|
||||
): Promise<PaginatedApiResponse<MembershipApplicationListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<MembershipApplicationListItem>>(
|
||||
'/v1/membership-applications',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load membership applications')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Get membership application
|
||||
export async function getMembershipApplication(id: string): Promise<MembershipApplicationApiResponse> {
|
||||
const { data } = await api.get<MembershipApplicationApiResponse>(`/v1/membership-applications/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load membership application')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Update membership application
|
||||
export async function updateMembershipApplication(
|
||||
id: string,
|
||||
payload: UpdateMembershipApplicationPayload,
|
||||
): Promise<MembershipApplicationUpdateResponse> {
|
||||
const { data } = await api.patch<MembershipApplicationUpdateResponse>(
|
||||
`/v1/membership-applications/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to update membership application')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Upload membership application document
|
||||
export async function uploadMembershipApplicationDocument(
|
||||
id: string,
|
||||
type: DocumentUploadType,
|
||||
file: File,
|
||||
): Promise<MembershipApplicationUpdateResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('type', type)
|
||||
formData.append('file', file)
|
||||
|
||||
const { data } = await api.post<MembershipApplicationUpdateResponse>(
|
||||
`/v1/membership-applications/${id}/documents`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to upload document')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Submit management review
|
||||
export async function submitManagementReview(
|
||||
id: string,
|
||||
payload: MembershipApplicationReviewPayload,
|
||||
): Promise<MembershipApplicationReviewResponse> {
|
||||
const { data } = await api.post<MembershipApplicationReviewResponse>(
|
||||
`/v1/membership-applications/${id}/management-review`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to submit management review')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Submit board review
|
||||
export async function submitBoardReview(
|
||||
id: string,
|
||||
payload: MembershipApplicationReviewPayload,
|
||||
): Promise<MembershipApplicationReviewResponse> {
|
||||
const { data } = await api.post<MembershipApplicationReviewResponse>(
|
||||
`/v1/membership-applications/${id}/board-review`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to submit board review')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Complete membership application
|
||||
export async function completeMembershipApplication(
|
||||
id: string,
|
||||
): Promise<MembershipApplicationReviewResponse> {
|
||||
const { data } = await api.post<MembershipApplicationReviewResponse>(
|
||||
`/v1/membership-applications/${id}/complete`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to complete membership application')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchCompleteMembershipApplications(
|
||||
applicationIds: string[],
|
||||
): Promise<BatchCompleteResponse> {
|
||||
const { data } = await api.post<BatchCompleteResponse>(
|
||||
'/v1/membership-applications/batch-complete',
|
||||
{ application_ids: applicationIds },
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Fetch membership application document/ view in modal
|
||||
export async function fetchMembershipApplicationDocument(
|
||||
applicationId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(
|
||||
`/v1/membership-applications/${applicationId}/documents/${documentId}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
// Download membership application document
|
||||
export async function downloadMembershipApplicationDocument(
|
||||
applicationId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<void> {
|
||||
const blob = await fetchMembershipApplicationDocument(applicationId, documentId, mimeType)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
export interface MembershipApplicationApplicantForm {
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
birth_date: string
|
||||
birth_place: string
|
||||
gender: string
|
||||
marriage_status: string
|
||||
address: string
|
||||
phone_number: string
|
||||
office_number: string
|
||||
postcode: string
|
||||
employer_name: string
|
||||
employer_address: string
|
||||
current_position: string
|
||||
start_work_date: string
|
||||
stock_monthly_contribution: string
|
||||
fee_monthly_contribution: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationHeirForm {
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationReferenceForm {
|
||||
ic_number: string
|
||||
user_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationReferencesForm {
|
||||
proposer: MembershipApplicationReferenceForm
|
||||
supporter: MembershipApplicationReferenceForm
|
||||
}
|
||||
|
||||
export interface MembershipApplicationDocumentsForm {
|
||||
ic_copy: File | null
|
||||
photo: File | null
|
||||
salary_slip: File | null
|
||||
employer_letter: File | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationFormState {
|
||||
applicant: MembershipApplicationApplicantForm
|
||||
heirs: MembershipApplicationHeirForm[]
|
||||
references: MembershipApplicationReferencesForm
|
||||
documents: MembershipApplicationDocumentsForm
|
||||
}
|
||||
|
||||
export interface MemberLookupResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
id: string
|
||||
name: string
|
||||
ic_number: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationSubmitResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
id: string
|
||||
application_number: string
|
||||
status: string
|
||||
submitted_at: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MembershipApplicationStatus =
|
||||
| 'SUBMITTED'
|
||||
| 'PENDING_BOARD'
|
||||
| 'MANAGEMENT_REJECTED'
|
||||
| 'PENDING_NOTIFICATION'
|
||||
| 'COMPLETED'
|
||||
|
||||
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
||||
|
||||
export interface MembershipApplicationApplicantSummary {
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationListItem {
|
||||
id: string
|
||||
application_number: string
|
||||
status: MembershipApplicationStatus
|
||||
board_result: MembershipApplicationBoardResult | null
|
||||
submitted_at: string | null
|
||||
applicant: MembershipApplicationApplicantSummary | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface ListMembershipApplicationsParams {
|
||||
page?: number
|
||||
per_page?: number
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
search?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationApplicantDetail {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
address: string | null
|
||||
phone_number: string | null
|
||||
office_number: string | null
|
||||
postcode: string | null
|
||||
employer_name: string | null
|
||||
employer_address: string | null
|
||||
current_position: string | null
|
||||
start_work_date: string | null
|
||||
stock_monthly_contribution: string | number | null
|
||||
fee_monthly_contribution: string | number | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationHeirDetail {
|
||||
id: string
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationReferenceMember {
|
||||
id: string
|
||||
name: string
|
||||
ic_number: string
|
||||
}
|
||||
|
||||
export interface MembershipApplicationReferenceDetail {
|
||||
id: string
|
||||
reference_type: 'PROPOSER' | 'SUPPORTER'
|
||||
user_id: string | null
|
||||
member: MembershipApplicationReferenceMember | null
|
||||
assigned_by: string | null
|
||||
assigned_at: string | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationDocumentDetail {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
mime_type: string | null
|
||||
file_size: number | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationReviewDetail {
|
||||
id: string
|
||||
stage: 'MANAGEMENT' | 'BOARD'
|
||||
decision: string | null
|
||||
remarks: string | null
|
||||
reviewer: { id: string; name: string } | null
|
||||
reviewed_at: string | null
|
||||
}
|
||||
|
||||
export interface MembershipApplicationDetail {
|
||||
id: string
|
||||
application_number: string
|
||||
status: MembershipApplicationStatus
|
||||
board_result: MembershipApplicationBoardResult | null
|
||||
submitted_at: string | null
|
||||
completed_at: string | null
|
||||
applicant: MembershipApplicationApplicantDetail | null
|
||||
heirs: MembershipApplicationHeirDetail[]
|
||||
references: MembershipApplicationReferenceDetail[]
|
||||
documents: MembershipApplicationDocumentDetail[]
|
||||
reviews: MembershipApplicationReviewDetail[]
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export type MembershipApplicationApiResponse = {
|
||||
success: boolean
|
||||
data: MembershipApplicationDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface UpdateMembershipApplicationPayload {
|
||||
applicant: Omit<MembershipApplicationApplicantForm, 'stock_monthly_contribution' | 'fee_monthly_contribution'> & {
|
||||
stock_monthly_contribution: number
|
||||
fee_monthly_contribution: number
|
||||
}
|
||||
heirs: MembershipApplicationHeirForm[]
|
||||
references: {
|
||||
proposer_ic_number: string
|
||||
supporter_ic_number: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MembershipApplicationUpdateResponse = MembershipApplicationApiResponse & {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type DocumentUploadType = 'ic_copy' | 'photo' | 'salary_slip' | 'employer_letter'
|
||||
|
||||
export type ManagementReviewDecision = 'APPROVED' | 'REJECTED'
|
||||
export type BoardReviewDecision = 'PASS' | 'FAIL'
|
||||
|
||||
export interface MembershipApplicationReviewPayload {
|
||||
decision: ManagementReviewDecision | BoardReviewDecision
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export type MembershipApplicationReviewResponse = MembershipApplicationApiResponse & {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BatchCompleteFailedItem {
|
||||
id: string
|
||||
application_number: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BatchCompleteResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
succeeded: MembershipApplicationDetail[]
|
||||
failed: BatchCompleteFailedItem[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import type {
|
||||
MembershipApplicationDetail,
|
||||
MembershipApplicationDocumentsForm,
|
||||
MembershipApplicationFormState,
|
||||
MembershipApplicationHeirForm,
|
||||
MembershipApplicationReferenceForm,
|
||||
UpdateMembershipApplicationPayload,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
export type SelectOption = { label: string; value: string }
|
||||
|
||||
export const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
export const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
export const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Isteri', value: 'Isteri' },
|
||||
{ label: 'Suami', value: 'Suami' },
|
||||
{ label: 'Anak', value: 'Anak' },
|
||||
{ label: 'Bapa', value: 'Bapa' },
|
||||
{ label: 'Ibu', value: 'Ibu' },
|
||||
{ label: 'Orang Tua', value: 'Orang Tua' },
|
||||
{ label: 'Saudara', value: 'Saudara' },
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
ic_copy: 'Salinan Kad Pengenalan',
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
}
|
||||
|
||||
export const DOCUMENT_UPLOAD_TYPES = ['ic_copy', 'photo', 'salary_slip', 'employer_letter'] as const
|
||||
export type DocumentUploadType = (typeof DOCUMENT_UPLOAD_TYPES)[number]
|
||||
|
||||
export function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
export function labelToApiValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
export function apiValueToLabel(options: SelectOption[], value: string | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
export function createEmptyReference(): MembershipApplicationReferenceForm {
|
||||
return {
|
||||
ic_number: '',
|
||||
user_id: '',
|
||||
name: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyHeir(): MembershipApplicationHeirForm {
|
||||
return {
|
||||
name: '',
|
||||
ic_number: '',
|
||||
relationship: '',
|
||||
phone_number: '',
|
||||
}
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
function toFormString(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function createEmptyFormState(): MembershipApplicationFormState {
|
||||
return {
|
||||
applicant: {
|
||||
name: '',
|
||||
email: '',
|
||||
ic_number: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
gender: '',
|
||||
marriage_status: '',
|
||||
address: '',
|
||||
phone_number: '',
|
||||
office_number: '',
|
||||
postcode: '',
|
||||
employer_name: '',
|
||||
employer_address: '',
|
||||
current_position: '',
|
||||
start_work_date: '',
|
||||
stock_monthly_contribution: '',
|
||||
fee_monthly_contribution: '',
|
||||
},
|
||||
heirs: [createEmptyHeir()],
|
||||
references: {
|
||||
proposer: createEmptyReference(),
|
||||
supporter: createEmptyReference(),
|
||||
},
|
||||
documents: {
|
||||
ic_copy: null,
|
||||
photo: null,
|
||||
salary_slip: null,
|
||||
employer_letter: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function detailToFormState(detail: MembershipApplicationDetail): MembershipApplicationFormState {
|
||||
const applicant = detail.applicant
|
||||
const proposer = detail.references.find((reference) => reference.reference_type === 'PROPOSER')
|
||||
const supporter = detail.references.find((reference) => reference.reference_type === 'SUPPORTER')
|
||||
|
||||
return {
|
||||
applicant: {
|
||||
name: toFormString(applicant?.name),
|
||||
email: toFormString(applicant?.email),
|
||||
ic_number: toFormString(applicant?.ic_number),
|
||||
birth_date: toDateInputValue(applicant?.birth_date),
|
||||
birth_place: toFormString(applicant?.birth_place),
|
||||
gender: toFormString(applicant?.gender),
|
||||
marriage_status: toFormString(applicant?.marriage_status),
|
||||
address: toFormString(applicant?.address),
|
||||
phone_number: toFormString(applicant?.phone_number),
|
||||
office_number: toFormString(applicant?.office_number),
|
||||
postcode: toFormString(applicant?.postcode),
|
||||
employer_name: toFormString(applicant?.employer_name),
|
||||
employer_address: toFormString(applicant?.employer_address),
|
||||
current_position: toFormString(applicant?.current_position),
|
||||
start_work_date: toDateInputValue(applicant?.start_work_date),
|
||||
stock_monthly_contribution: toFormString(applicant?.stock_monthly_contribution),
|
||||
fee_monthly_contribution: toFormString(applicant?.fee_monthly_contribution),
|
||||
},
|
||||
heirs: detail.heirs.length
|
||||
? detail.heirs.map((heir) => ({
|
||||
name: toFormString(heir.name),
|
||||
ic_number: toFormString(heir.ic_number),
|
||||
relationship: toFormString(heir.relationship),
|
||||
phone_number: toFormString(heir.phone_number),
|
||||
}))
|
||||
: [createEmptyHeir()],
|
||||
references: {
|
||||
proposer: {
|
||||
ic_number: toFormString(proposer?.member?.ic_number),
|
||||
user_id: toFormString(proposer?.user_id),
|
||||
name: toFormString(proposer?.member?.name),
|
||||
},
|
||||
supporter: {
|
||||
ic_number: toFormString(supporter?.member?.ic_number),
|
||||
user_id: toFormString(supporter?.user_id),
|
||||
name: toFormString(supporter?.member?.name),
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
ic_copy: null,
|
||||
photo: null,
|
||||
salary_slip: null,
|
||||
employer_letter: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdatePayload(form: MembershipApplicationFormState): UpdateMembershipApplicationPayload {
|
||||
return {
|
||||
applicant: {
|
||||
...form.applicant,
|
||||
stock_monthly_contribution: Number(form.applicant.stock_monthly_contribution || 0),
|
||||
fee_monthly_contribution: Number(form.applicant.fee_monthly_contribution || 0),
|
||||
},
|
||||
heirs: form.heirs,
|
||||
references: {
|
||||
proposer_ic_number: form.references.proposer.ic_number.trim(),
|
||||
supporter_ic_number: form.references.supporter.ic_number.trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyDocumentsForm(): MembershipApplicationDocumentsForm {
|
||||
return {
|
||||
ic_copy: null,
|
||||
photo: null,
|
||||
salary_slip: null,
|
||||
employer_letter: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
listNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead,
|
||||
} from '../services/notification.service'
|
||||
import type { NotificationItem } from '../types/notification.types'
|
||||
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
export function useNotifications() {
|
||||
async function fetchNotifications(perPage = 5) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await listNotifications({ per_page: perPage, page: 1 })
|
||||
notifications.value = response.data
|
||||
unreadCount.value = response.unread_count
|
||||
} catch (fetchError) {
|
||||
error.value = getApiErrorMessage(fetchError, 'Gagal memuatkan notifikasi.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(id: string) {
|
||||
const notification = notifications.value.find((item) => item.id === id)
|
||||
if (!notification || notification.is_read) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await markNotificationAsRead(id)
|
||||
notification.is_read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (markError) {
|
||||
error.value = getApiErrorMessage(markError, 'Gagal menandakan notifikasi sebagai dibaca.')
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (unreadCount.value === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await markAllNotificationsAsRead()
|
||||
notifications.value = notifications.value.map((notification) => ({
|
||||
...notification,
|
||||
is_read: true,
|
||||
}))
|
||||
unreadCount.value = 0
|
||||
} catch (markError) {
|
||||
error.value = getApiErrorMessage(markError, 'Gagal menandakan semua notifikasi sebagai dibaca.')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useNotifications } from './composables/useNotifications'
|
||||
export { resolveNotificationRoute } from './utils/notification-navigation'
|
||||
export type { NotificationItem } from './types/notification.types'
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
ListNotificationsParams,
|
||||
NotificationActionApiResponse,
|
||||
NotificationCountApiResponse,
|
||||
NotificationsApiResponse,
|
||||
} from '../types/notification.types'
|
||||
|
||||
export async function listNotifications(
|
||||
params: ListNotificationsParams = {},
|
||||
): Promise<NotificationsApiResponse> {
|
||||
const { data } = await api.get<NotificationsApiResponse>('/v1/notifications', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan notifikasi.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getNotificationCount(): Promise<NotificationCountApiResponse> {
|
||||
const { data } = await api.get<NotificationCountApiResponse>('/v1/notifications/count')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan bilangan notifikasi.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string): Promise<NotificationActionApiResponse> {
|
||||
const { data } = await api.patch<NotificationActionApiResponse>(`/v1/notifications/${id}/read`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menandakan notifikasi sebagai dibaca.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead(): Promise<NotificationActionApiResponse> {
|
||||
const { data } = await api.patch<NotificationActionApiResponse>('/v1/notifications/mark-all-read')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menandakan semua notifikasi sebagai dibaca.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type NotificationNavigation = {
|
||||
route: string
|
||||
params: Record<string, string>
|
||||
query: Record<string, string>
|
||||
}
|
||||
|
||||
export type NotificationData = {
|
||||
user_id?: string
|
||||
sender_id?: string | null
|
||||
type?: string
|
||||
message?: string
|
||||
user_name?: string
|
||||
user_email?: string
|
||||
user_status?: string
|
||||
}
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
time_ago: string
|
||||
icon: string
|
||||
color: string
|
||||
navigation: NotificationNavigation
|
||||
data: NotificationData
|
||||
}
|
||||
|
||||
export type NotificationPagination = {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type NotificationsApiResponse = {
|
||||
success: boolean
|
||||
data: NotificationItem[]
|
||||
pagination: NotificationPagination
|
||||
unread_count: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type NotificationCountApiResponse = {
|
||||
success: boolean
|
||||
unread_count: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type NotificationActionApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type ListNotificationsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
filter?: 'all' | 'unread' | 'read'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { NotificationItem } from '../types/notification.types'
|
||||
|
||||
export function resolveNotificationRoute(notification: NotificationItem): RouteLocationRaw | null {
|
||||
const userId = notification.data.user_id
|
||||
|
||||
if (notification.type === 'user_activation_required') {
|
||||
if (userId) {
|
||||
return { name: 'edit-user', params: { id: String(userId) } }
|
||||
}
|
||||
|
||||
return { name: 'list-users' }
|
||||
}
|
||||
|
||||
const route = notification.navigation.route
|
||||
|
||||
if (route === '/users') {
|
||||
if (userId) {
|
||||
return { name: 'edit-user', params: { id: String(userId) } }
|
||||
}
|
||||
|
||||
return { name: 'list-users' }
|
||||
}
|
||||
|
||||
if (route === '/dashboard') {
|
||||
return { name: 'dashboard-overview-1' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { listActiveBanks } from '@/modules/profile/services/bank.service'
|
||||
import {
|
||||
createBankDetail,
|
||||
deleteBankDetail,
|
||||
listBankDetails,
|
||||
updateBankDetail,
|
||||
} from '@/modules/profile/services/bankDetail.service'
|
||||
import type { Bank } from '@/modules/profile/types/bank.types'
|
||||
import type { BankDetail, BankDetailPayload } from '@/modules/profile/types/bankDetail.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const banks = ref<Bank[]>([])
|
||||
const loadingBanks = ref(false)
|
||||
const bankDetails = ref<BankDetail[]>([])
|
||||
const loadingBankDetails = ref(false)
|
||||
const savingBankDetail = ref(false)
|
||||
const deletingBankDetailId = ref<string | null>(null)
|
||||
const editingBankDetailId = ref<string | null>(null)
|
||||
|
||||
type BankDetailFieldKey = 'bank_id' | 'account_name' | 'account_number' | 'account_type'
|
||||
|
||||
const BANK_DETAIL_FIELD_KEYS: BankDetailFieldKey[] = [
|
||||
'bank_id',
|
||||
'account_name',
|
||||
'account_number',
|
||||
'account_type',
|
||||
]
|
||||
|
||||
const bankDetailErrors = reactive<Partial<Record<BankDetailFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Simpanan', value: 'Saving' },
|
||||
{ label: 'Semasa', value: 'Current' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const bankOptions = computed<SelectOption[]>(() =>
|
||||
banks.value.map((bank) => ({
|
||||
label: `${bank.name} (${bank.code})`,
|
||||
value: bank.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const bankCollection = computed(() => createSelectCollection(bankOptions.value))
|
||||
const accountTypeCollection = createSelectCollection(ACCOUNT_TYPE_OPTIONS)
|
||||
|
||||
const bankValue = ref<string[]>([])
|
||||
const bankInitial = ref<string[]>([])
|
||||
const accountTypeValue = ref<string[]>([])
|
||||
const accountTypeInitial = ref<string[]>([])
|
||||
|
||||
const bankNameById = computed(() =>
|
||||
Object.fromEntries(banks.value.map((bank) => [bank.id, `${bank.name} (${bank.code})`])),
|
||||
)
|
||||
|
||||
const accountTypeLabel = computed(() =>
|
||||
Object.fromEntries(ACCOUNT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingBankDetail = computed(() => editingBankDetailId.value !== null)
|
||||
|
||||
function emptyBankDetailForm() {
|
||||
return {
|
||||
bank_id: '',
|
||||
account_name: '',
|
||||
account_number: '',
|
||||
account_type: '',
|
||||
}
|
||||
}
|
||||
|
||||
const bankDetailForm = reactive(emptyBankDetailForm())
|
||||
|
||||
function clearBankDetailFieldError(field: BankDetailFieldKey) {
|
||||
delete bankDetailErrors[field]
|
||||
}
|
||||
|
||||
function clearBankDetailErrors() {
|
||||
for (const field of BANK_DETAIL_FIELD_KEYS) {
|
||||
delete bankDetailErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setBankDetailErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (BANK_DETAIL_FIELD_KEYS.includes(field as BankDetailFieldKey) && messages[0]) {
|
||||
bankDetailErrors[field as BankDetailFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(bankDetailErrors).length > 0
|
||||
}
|
||||
|
||||
function setBankValue(details: { value: string[] }) {
|
||||
bankValue.value = details.value
|
||||
clearBankDetailFieldError('bank_id')
|
||||
bankDetailForm.bank_id = labelToApiValue(bankOptions.value, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setAccountTypeValue(details: { value: string[] }) {
|
||||
accountTypeValue.value = details.value
|
||||
clearBankDetailFieldError('account_type')
|
||||
bankDetailForm.account_type =
|
||||
labelToApiValue(ACCOUNT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncBankDetailSelectValues() {
|
||||
bankValue.value = apiValueToLabel(bankOptions.value, bankDetailForm.bank_id)
|
||||
bankInitial.value = [...bankValue.value]
|
||||
accountTypeValue.value = apiValueToLabel(ACCOUNT_TYPE_OPTIONS, bankDetailForm.account_type)
|
||||
accountTypeInitial.value = [...accountTypeValue.value]
|
||||
}
|
||||
|
||||
function resetBankDetailForm() {
|
||||
Object.assign(bankDetailForm, emptyBankDetailForm())
|
||||
editingBankDetailId.value = null
|
||||
clearBankDetailErrors()
|
||||
syncBankDetailSelectValues()
|
||||
}
|
||||
|
||||
function validateBankDetailForm(): boolean {
|
||||
clearBankDetailErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!bankValue.value[0] || !labelToApiValue(bankOptions.value, bankValue.value[0])) {
|
||||
bankDetailErrors.bank_id = 'Bank diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!bankDetailForm.account_name.trim()) {
|
||||
bankDetailErrors.account_name = 'Nama akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!bankDetailForm.account_number.trim()) {
|
||||
bankDetailErrors.account_number = 'Nombor akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!accountTypeValue.value[0] ||
|
||||
!labelToApiValue(ACCOUNT_TYPE_OPTIONS, accountTypeValue.value[0])
|
||||
) {
|
||||
bankDetailErrors.account_type = 'Jenis akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildBankDetailPayload(): BankDetailPayload {
|
||||
return {
|
||||
bank_id: labelToApiValue(bankOptions.value, bankValue.value[0]) ?? bankDetailForm.bank_id,
|
||||
account_name: bankDetailForm.account_name.trim(),
|
||||
account_number: bankDetailForm.account_number.trim(),
|
||||
account_type:
|
||||
labelToApiValue(ACCOUNT_TYPE_OPTIONS, accountTypeValue.value[0]) ??
|
||||
bankDetailForm.account_type,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveBanks() {
|
||||
loadingBanks.value = true
|
||||
|
||||
try {
|
||||
const res = await listActiveBanks()
|
||||
banks.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan senarai bank.'),
|
||||
})
|
||||
} finally {
|
||||
loadingBanks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBankDetails() {
|
||||
loadingBankDetails.value = true
|
||||
|
||||
try {
|
||||
const res = await listBankDetails()
|
||||
bankDetails.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan akaun bank.'),
|
||||
})
|
||||
} finally {
|
||||
loadingBankDetails.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditBankDetail(bankDetail: BankDetail) {
|
||||
clearBankDetailErrors()
|
||||
editingBankDetailId.value = bankDetail.id
|
||||
bankDetailForm.bank_id = bankDetail.bank_id
|
||||
bankDetailForm.account_name = bankDetail.account_name
|
||||
bankDetailForm.account_number = bankDetail.account_number
|
||||
bankDetailForm.account_type = bankDetail.account_type
|
||||
syncBankDetailSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveBankDetail() {
|
||||
if (!validateBankDetailForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingBankDetail.value = true
|
||||
const wasEditing = isEditingBankDetail.value
|
||||
const payload = buildBankDetailPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateBankDetail(editingBankDetailId.value!, payload)
|
||||
: await createBankDetail(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan akaun bank.')
|
||||
}
|
||||
|
||||
await fetchBankDetails()
|
||||
resetBankDetailForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Akaun bank berjaya dikemas kini.' : 'Akaun bank berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setBankDetailErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan akaun bank.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingBankDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteBankDetail(bankDetail: BankDetail) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam akaun bank?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingBankDetailId.value = bankDetail.id
|
||||
|
||||
try {
|
||||
const res = await deleteBankDetail(bankDetail.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam akaun bank.')
|
||||
}
|
||||
|
||||
if (editingBankDetailId.value === bankDetail.id) {
|
||||
resetBankDetailForm()
|
||||
}
|
||||
|
||||
await fetchBankDetails()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Akaun bank berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam akaun bank.'),
|
||||
})
|
||||
} finally {
|
||||
deletingBankDetailId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchActiveBanks()
|
||||
syncBankDetailSelectValues()
|
||||
await fetchBankDetails()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<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">Akaun Bank</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat akaun bank anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingBankDetails" class="text-sm text-slate-500">
|
||||
Memuatkan akaun bank...
|
||||
</div>
|
||||
|
||||
<div v-else-if="bankDetails.length" class="space-y-3">
|
||||
<div
|
||||
v-for="bankDetail in bankDetails"
|
||||
:key="bankDetail.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">
|
||||
{{ bankNameById[bankDetail.bank_id] ?? bankDetail.bank_id }}
|
||||
</span>
|
||||
<Badge look="outline">
|
||||
{{ accountTypeLabel[bankDetail.account_type] ?? bankDetail.account_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ bankDetail.account_name }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ bankDetail.account_number }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingBankDetailId === bankDetail.id"
|
||||
@click="startEditBankDetail(bankDetail)"
|
||||
>
|
||||
<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="deletingBankDetailId === bankDetail.id"
|
||||
@click="onDeleteBankDetail(bankDetail)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingBankDetailId === bankDetail.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingBankDetailId === bankDetail.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 akaun bank direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveBankDetail">
|
||||
<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">
|
||||
{{ isEditingBankDetail ? 'Kemaskini Akaun Bank' : 'Tambah Akaun Bank' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingBankDetail
|
||||
? 'Kemas kini maklumat akaun bank yang dipilih.'
|
||||
: 'Tambah akaun bank baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingBankDetail"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingBankDetail"
|
||||
@click="resetBankDetailForm"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingBankDetail || loadingBanks">
|
||||
{{ savingBankDetail ? 'Menyimpan...' : isEditingBankDetail ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Bank</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`bank-${editingBankDetailId ?? 'new'}-${banks.length}`"
|
||||
class="w-full"
|
||||
:collection="bankCollection"
|
||||
:default-value="bankInitial"
|
||||
:disabled="savingBankDetail || loadingBanks"
|
||||
@value-change="setBankValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!bankDetailErrors.bank_id">
|
||||
<SelectValueText placeholder="Pilih bank" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Bank</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in bankCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="bankDetailErrors.bank_id">{{ bankDetailErrors.bank_id }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Akaun</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`account-type-${editingBankDetailId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="accountTypeCollection"
|
||||
:default-value="accountTypeInitial"
|
||||
:disabled="savingBankDetail"
|
||||
@value-change="setAccountTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!bankDetailErrors.account_type">
|
||||
<SelectValueText placeholder="Pilih jenis akaun" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Akaun</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in accountTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="bankDetailErrors.account_type">
|
||||
{{ bankDetailErrors.account_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="bank-account-name">Nama Akaun</FieldLabel>
|
||||
<Input
|
||||
id="bank-account-name"
|
||||
v-model="bankDetailForm.account_name"
|
||||
type="text"
|
||||
placeholder="Nama pemegang akaun"
|
||||
:aria-invalid="!!bankDetailErrors.account_name"
|
||||
@input="clearBankDetailFieldError('account_name')"
|
||||
/>
|
||||
<FieldError v-if="bankDetailErrors.account_name">
|
||||
{{ bankDetailErrors.account_name }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="bank-account-number">Nombor Akaun</FieldLabel>
|
||||
<Input
|
||||
id="bank-account-number"
|
||||
v-model="bankDetailForm.account_number"
|
||||
type="text"
|
||||
placeholder="Nombor akaun"
|
||||
:aria-invalid="!!bankDetailErrors.account_number"
|
||||
@input="clearBankDetailFieldError('account_number')"
|
||||
/>
|
||||
<FieldError v-if="bankDetailErrors.account_number">
|
||||
{{ bankDetailErrors.account_number }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,541 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
createEmployment,
|
||||
deleteEmployment,
|
||||
listEmployments,
|
||||
updateEmployment,
|
||||
} from '@/modules/profile/services/employment.service'
|
||||
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const employments = ref<Employment[]>([])
|
||||
const loadingEmployments = ref(false)
|
||||
const savingEmployment = ref(false)
|
||||
const deletingEmploymentId = ref<string | null>(null)
|
||||
const editingEmploymentId = ref<string | null>(null)
|
||||
|
||||
type EmploymentFieldKey =
|
||||
| 'company_name'
|
||||
| 'job_title'
|
||||
| 'employment_type'
|
||||
| 'salary'
|
||||
| 'start_date'
|
||||
| 'end_date'
|
||||
| 'is_current'
|
||||
|
||||
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
|
||||
'company_name',
|
||||
'job_title',
|
||||
'employment_type',
|
||||
'salary',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'is_current',
|
||||
]
|
||||
|
||||
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tetap', value: 'Permanent' },
|
||||
{ label: 'Kontrak', value: 'Contract' },
|
||||
{ label: 'Latihan Industri', value: 'Internship' },
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
|
||||
function clearEmploymentErrors() {
|
||||
for (const field of EMPLOYMENT_FIELD_KEYS) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setEmploymentErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
|
||||
employmentErrors[field as EmploymentFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(employmentErrors).length > 0
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function emptyEmploymentForm() {
|
||||
return {
|
||||
company_name: '',
|
||||
job_title: '',
|
||||
employment_type: '',
|
||||
salary: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
is_current: true,
|
||||
}
|
||||
}
|
||||
|
||||
const employmentForm = reactive(emptyEmploymentForm())
|
||||
|
||||
const employmentTypeLabel = computed(() =>
|
||||
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
employmentForm.employment_type =
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
Object.assign(employmentForm, emptyEmploymentForm())
|
||||
editingEmploymentId.value = null
|
||||
clearEmploymentErrors()
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
function formatSalary(value: number | string | null | undefined): string {
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function formatEmploymentPeriod(employment: Employment): string {
|
||||
const start = formatDateLabel(employment.start_date)
|
||||
if (employment.is_current) {
|
||||
return `${start} - Kini`
|
||||
}
|
||||
const end = formatDateLabel(employment.end_date)
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
function validateEmploymentForm(): boolean {
|
||||
clearEmploymentErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!employmentForm.company_name.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.job_title.trim()) {
|
||||
employmentErrors.job_title = 'Jawatan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentTypeValue.value[0] ||
|
||||
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
|
||||
) {
|
||||
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
const salary = Number(employmentForm.salary)
|
||||
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
|
||||
employmentErrors.salary = 'Gaji diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.start_date) {
|
||||
employmentErrors.start_date = 'Tarikh mula diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.is_current && !employmentForm.end_date) {
|
||||
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentForm.is_current &&
|
||||
employmentForm.start_date &&
|
||||
employmentForm.end_date &&
|
||||
employmentForm.end_date < employmentForm.start_date
|
||||
) {
|
||||
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildEmploymentPayload(): EmploymentPayload {
|
||||
return {
|
||||
company_name: employmentForm.company_name.trim(),
|
||||
job_title: employmentForm.job_title.trim(),
|
||||
employment_type:
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
|
||||
employmentForm.employment_type,
|
||||
salary: Number(employmentForm.salary),
|
||||
start_date: employmentForm.start_date,
|
||||
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
|
||||
is_current: employmentForm.is_current,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEmployments() {
|
||||
loadingEmployments.value = true
|
||||
|
||||
try {
|
||||
const res = await listEmployments()
|
||||
employments.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
loadingEmployments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditEmployment(employment: Employment) {
|
||||
clearEmploymentErrors()
|
||||
editingEmploymentId.value = employment.id
|
||||
employmentForm.company_name = employment.company_name
|
||||
employmentForm.job_title = employment.job_title
|
||||
employmentForm.employment_type = employment.employment_type
|
||||
employmentForm.salary = String(employment.salary)
|
||||
employmentForm.start_date = toDateInputValue(employment.start_date)
|
||||
employmentForm.end_date = toDateInputValue(employment.end_date)
|
||||
employmentForm.is_current = employment.is_current
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveEmployment() {
|
||||
if (!validateEmploymentForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingEmployment.value = true
|
||||
const wasEditing = isEditingEmployment.value
|
||||
const payload = buildEmploymentPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateEmployment(editingEmploymentId.value!, payload)
|
||||
: await createEmployment(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
resetEmploymentForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setEmploymentErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pekerjaan.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingEmployment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteEmployment(employment: Employment) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pekerjaan?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingEmploymentId.value = employment.id
|
||||
|
||||
try {
|
||||
const res = await deleteEmployment(employment.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
if (editingEmploymentId.value === employment.id) {
|
||||
resetEmploymentForm()
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pekerjaan berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
deletingEmploymentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => employmentForm.is_current,
|
||||
(isCurrent) => {
|
||||
if (isCurrent) {
|
||||
employmentForm.end_date = ''
|
||||
clearEmploymentFieldError('end_date')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
syncEmploymentSelectValues()
|
||||
await fetchEmployments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<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">Pekerjaan</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus sejarah dan maklumat pekerjaan anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingEmployments" class="text-sm text-slate-500">
|
||||
Memuatkan pekerjaan...
|
||||
</div>
|
||||
|
||||
<div v-else-if="employments.length" class="space-y-3">
|
||||
<div v-for="employment in employments" :key="employment.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">{{ employment.company_name }}</span>
|
||||
<Badge class="bg-green-500 text-white" v-if="employment.is_current">Semasa</Badge>
|
||||
<Badge look="outline">
|
||||
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingEmploymentId === employment.id" @click="startEditEmployment(employment)">
|
||||
<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="deletingEmploymentId === employment.id" @click="onDeleteEmployment(employment)">
|
||||
<Lucide class="mr-2 size-4" :icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingEmploymentId === employment.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 pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form 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">
|
||||
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingEmployment
|
||||
? 'Kemas kini maklumat pekerjaan yang dipilih.'
|
||||
: 'Tambah rekod pekerjaan baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="isEditingEmployment" type="button" variant="ghost"
|
||||
class="border border-foreground/15 shadow-none" :disabled="savingEmployment"
|
||||
@click="resetEmploymentForm">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingEmployment">
|
||||
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')" />
|
||||
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
|
||||
<Input id="employment-job-title" v-model="employmentForm.job_title" type="text" placeholder="Jawatan"
|
||||
:aria-invalid="!!employmentErrors.job_title" @input="clearEmploymentFieldError('job_title')" />
|
||||
<FieldError v-if="employmentErrors.job_title">{{ employmentErrors.job_title }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Kerja</FieldLabel>
|
||||
<SelectRoot :key="`employment-type-${editingEmploymentId ?? 'new'}`" class="w-full"
|
||||
:collection="employmentTypeCollection" :default-value="employmentTypeInitial"
|
||||
:disabled="savingEmployment" @value-change="setEmploymentTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
|
||||
<SelectValueText placeholder="Pilih jenis kerja" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in employmentTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.employment_type">
|
||||
{{ employmentErrors.employment_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
|
||||
<Input id="employment-salary" v-model="employmentForm.salary" type="number" min="0" step="0.01"
|
||||
placeholder="0.00" :aria-invalid="!!employmentErrors.salary"
|
||||
@input="clearEmploymentFieldError('salary')" />
|
||||
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
|
||||
<Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
|
||||
:aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" />
|
||||
<FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
|
||||
<Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
|
||||
:disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date"
|
||||
@input="clearEmploymentFieldError('end_date')" />
|
||||
<FieldError v-if="employmentErrors.end_date">{{ employmentErrors.end_date }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot :checked="employmentForm.is_current" :disabled="savingEmployment"
|
||||
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,516 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createHeir, deleteHeir, listHeirs, updateHeir } from '@/modules/profile/services/heir.service'
|
||||
import type { Heir, HeirPayload } from '@/modules/profile/types/heir.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const heirs = ref<Heir[]>([])
|
||||
const loadingHeirs = ref(false)
|
||||
const savingHeir = ref(false)
|
||||
const deletingHeirId = ref<string | null>(null)
|
||||
const editingHeirId = ref<string | null>(null)
|
||||
|
||||
type HeirFieldKey =
|
||||
| 'name'
|
||||
| 'ic_number'
|
||||
| 'relationship'
|
||||
| 'phone_number'
|
||||
| 'address'
|
||||
| 'is_primary'
|
||||
|
||||
const HEIR_FIELD_KEYS: HeirFieldKey[] = [
|
||||
'name',
|
||||
'ic_number',
|
||||
'relationship',
|
||||
'phone_number',
|
||||
'address',
|
||||
'is_primary',
|
||||
]
|
||||
|
||||
const heirErrors = reactive<Partial<Record<HeirFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Isteri', value: 'Isteri' },
|
||||
{ label: 'Suami', value: 'Suami' },
|
||||
{ label: 'Anak', value: 'Anak' },
|
||||
{ label: 'Bapa', value: 'Bapa' },
|
||||
{ label: 'Ibu', value: 'Ibu' },
|
||||
{ label: 'Orang Tua', value: 'Orang Tua' },
|
||||
{ label: 'Saudara', value: 'Saudara' },
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
|
||||
const relationshipValue = ref<string[]>([])
|
||||
const relationshipInitial = ref<string[]>([])
|
||||
|
||||
const isEditingHeir = computed(() => editingHeirId.value !== null)
|
||||
|
||||
function emptyHeirForm() {
|
||||
return {
|
||||
name: '',
|
||||
ic_number: '',
|
||||
relationship: '',
|
||||
phone_number: '',
|
||||
address: '',
|
||||
is_primary: false,
|
||||
}
|
||||
}
|
||||
|
||||
const heirForm = reactive(emptyHeirForm())
|
||||
|
||||
function clearHeirFieldError(field: HeirFieldKey) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
|
||||
function clearHeirErrors() {
|
||||
for (const field of HEIR_FIELD_KEYS) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setHeirErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (HEIR_FIELD_KEYS.includes(field as HeirFieldKey) && messages[0]) {
|
||||
heirErrors[field as HeirFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(heirErrors).length > 0
|
||||
}
|
||||
|
||||
function setRelationshipValue(details: { value: string[] }) {
|
||||
relationshipValue.value = details.value
|
||||
clearHeirFieldError('relationship')
|
||||
heirForm.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncHeirSelectValues() {
|
||||
relationshipValue.value = apiValueToLabel(RELATIONSHIP_OPTIONS, heirForm.relationship)
|
||||
relationshipInitial.value = [...relationshipValue.value]
|
||||
}
|
||||
|
||||
function resetHeirForm() {
|
||||
Object.assign(heirForm, emptyHeirForm())
|
||||
editingHeirId.value = null
|
||||
clearHeirErrors()
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
function validateHeirForm(): boolean {
|
||||
clearHeirErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!heirForm.name.trim()) {
|
||||
heirErrors.name = 'Nama diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.ic_number.trim()) {
|
||||
heirErrors.ic_number = 'No. kad pengenalan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!relationshipValue.value[0] ||
|
||||
!labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0])
|
||||
) {
|
||||
heirErrors.relationship = 'Hubungan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.phone_number.trim()) {
|
||||
heirErrors.phone_number = 'No. telefon diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.address.trim()) {
|
||||
heirErrors.address = 'Alamat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildHeirPayload(): HeirPayload {
|
||||
return {
|
||||
name: heirForm.name.trim(),
|
||||
ic_number: heirForm.ic_number.trim(),
|
||||
relationship:
|
||||
labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0]) ?? heirForm.relationship,
|
||||
phone_number: heirForm.phone_number.trim(),
|
||||
address: heirForm.address.trim(),
|
||||
is_primary: heirForm.is_primary,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHeirs() {
|
||||
loadingHeirs.value = true
|
||||
|
||||
try {
|
||||
const res = await listHeirs()
|
||||
heirs.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
loadingHeirs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditHeir(heir: Heir) {
|
||||
clearHeirErrors()
|
||||
editingHeirId.value = heir.id
|
||||
heirForm.name = heir.name
|
||||
heirForm.ic_number = heir.ic_number
|
||||
heirForm.relationship = heir.relationship
|
||||
heirForm.phone_number = heir.phone_number
|
||||
heirForm.address = heir.address
|
||||
heirForm.is_primary = heir.is_primary
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveHeir() {
|
||||
if (!validateHeirForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingHeir.value = true
|
||||
const wasEditing = isEditingHeir.value
|
||||
const payload = buildHeirPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateHeir(editingHeirId.value!, payload)
|
||||
: await createHeir(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
resetHeirForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setHeirErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingHeir.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteHeir(heir: Heir) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pewaris?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingHeirId.value = heir.id
|
||||
|
||||
try {
|
||||
const res = await deleteHeir(heir.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pewaris.')
|
||||
}
|
||||
|
||||
if (editingHeirId.value === heir.id) {
|
||||
resetHeirForm()
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pewaris berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
deletingHeirId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncHeirSelectValues()
|
||||
await fetchHeirs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<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>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat pewaris anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHeirs" class="text-sm text-slate-500">
|
||||
Memuatkan pewaris...
|
||||
</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 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>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
<Badge look="outline">{{ heir.relationship }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.ic_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.phone_number }}</p>
|
||||
<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)"
|
||||
>
|
||||
<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 }"
|
||||
/>
|
||||
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>
|
||||
|
||||
<form 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' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingHeir
|
||||
? 'Kemas kini maklumat pewaris yang dipilih.'
|
||||
: 'Tambah pewaris 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"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingHeir">
|
||||
{{ savingHeir ? 'Menyimpan...' : isEditingHeir ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<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')"
|
||||
/>
|
||||
<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')"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in relationshipCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<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')"
|
||||
/>
|
||||
<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')"
|
||||
/>
|
||||
<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)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pewaris utama</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
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'
|
||||
@@ -17,51 +16,34 @@ import {
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { updateProfile, uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ChangePassword from './ChangePassword.vue'
|
||||
import ProfileTab from './ProfileTab.vue'
|
||||
import EmploymentTab from './EmploymentTab.vue'
|
||||
import BankDetailTab from './BankDetailTab.vue'
|
||||
import HeirTab from './HeirTab.vue'
|
||||
import ChangePasswordTab from './ChangePasswordTab.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const uploadingImage = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePreviewUrl = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
})
|
||||
|
||||
const displayValue = (value: string | null | undefined) => value?.trim() || '-'
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = authStore.user?.status
|
||||
if (!status) return '-'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const avatarSrc = computed(
|
||||
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
||||
)
|
||||
|
||||
function syncFormFromUser() {
|
||||
const user = authStore.user
|
||||
if (!user) return
|
||||
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
}
|
||||
|
||||
function clearImagePreview() {
|
||||
if (imagePreviewUrl.value) {
|
||||
URL.revokeObjectURL(imagePreviewUrl.value)
|
||||
@@ -100,7 +82,7 @@ async function onImageSelected(event: Event) {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Gambar profil berjaya dikemas kini.',
|
||||
title: 'Gambar profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -116,50 +98,10 @@ async function onImageSelected(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProfile() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim(),
|
||||
})
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal mengemas kini profil.')
|
||||
}
|
||||
|
||||
authStore.setUserProfile(res.data)
|
||||
syncFormFromUser()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal mengemas kini profil.'),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authStore.user, syncFormFromUser, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchSession()
|
||||
}
|
||||
syncFormFromUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -191,7 +133,6 @@ onMounted(async () => {
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
{{ authStore.userName || '-' }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ authStore.userActiveRole }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -214,87 +155,90 @@ onMounted(async () => {
|
||||
</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="grid grid-cols-3 gap-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ displayValue(authStore.activeRole?.name) }}</div>
|
||||
<div class="opacity-70">Peranan</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ statusLabel }}</div>
|
||||
<div class="opacity-70">Status</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium capitalize">{{ displayValue(authStore.activeRole?.context) }}</div>
|
||||
<div class="opacity-70">Konteks</div>
|
||||
<div
|
||||
class="relative aspect-[1.75/1] w-full max-w-[17rem] overflow-hidden rounded-2xl bg-gradient-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
|
||||
role="img"
|
||||
aria-label="Kad digital anggota">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs title -->
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="w-full mb-0">
|
||||
<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
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
|
||||
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Maklumat Bank
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
</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>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="4">
|
||||
<Lucide class="mr-2 size-4" icon="Server" /> Perkhidmatan
|
||||
<Lucide class="mr-2 size-4" icon="MoreHorizontal" /> Lain-lain
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- Profile Info -->
|
||||
<TabsContent value="1" class="mt-8">
|
||||
<Box raised="single" class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="onSaveProfile">
|
||||
<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">Maklumat Peribadi</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Kemas kini maklumat peribadi anda.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="profile-name">Nama</FieldLabel>
|
||||
<Input id="profile-name" v-model="form.name" type="text" placeholder="Nama penuh" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-email">E-mel</FieldLabel>
|
||||
<Input id="profile-email" :model-value="authStore.user?.email ?? ''" type="email" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="profile-ic" v-model="form.ic_number" type="text" placeholder="No. kad pengenalan" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone" v-model="form.phone_number" type="text" placeholder="No. telefon" />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Box>
|
||||
<ProfileTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Pekerjaan -->
|
||||
<TabsContent value="5" class="mt-8">
|
||||
<EmploymentTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Bank -->
|
||||
<TabsContent value="3" class="mt-8">
|
||||
<BankDetailTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Password Change -->
|
||||
<TabsContent value="2" class="mt-8">
|
||||
<ChangePassword embedded />
|
||||
<ChangePasswordTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Maklumat Bank -->
|
||||
<!-- Pewaris -->
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<HeirTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Perkhidmatan -->
|
||||
<TabsContent value="4" class="mt-8">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<!-- BEGIN: Latest Uploads -->
|
||||
@@ -627,7 +571,7 @@ onMounted(async () => {
|
||||
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
|
||||
<Badge look="outline" class="mr-3 px-3 py-2">{{
|
||||
faker['dates'][0]
|
||||
}}</Badge>
|
||||
}}</Badge>
|
||||
<div class="opacity-70">Date of Release</div>
|
||||
</div>
|
||||
<div class="mt-5 flex sm:ml-auto sm:mt-0">
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
createAddress,
|
||||
deleteAddress,
|
||||
listAddresses,
|
||||
updateAddress,
|
||||
} from '@/modules/profile/services/address.service'
|
||||
import { updateProfile } from '@/modules/profile/services/profile.service'
|
||||
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
|
||||
type ProfileFieldKey = 'gender' | 'marriage_status' | 'birth_date' | 'birth_place'
|
||||
|
||||
const PROFILE_FIELD_KEYS: ProfileFieldKey[] = [
|
||||
'gender',
|
||||
'marriage_status',
|
||||
'birth_date',
|
||||
'birth_place',
|
||||
]
|
||||
|
||||
const profileErrors = reactive<Partial<Record<ProfileFieldKey, string>>>({})
|
||||
|
||||
const addresses = ref<Address[]>([])
|
||||
const loadingAddresses = ref(false)
|
||||
const savingAddress = ref(false)
|
||||
const deletingAddressId = ref<string | null>(null)
|
||||
const editingAddressId = ref<string | null>(null)
|
||||
|
||||
type AddressFieldKey =
|
||||
| 'address_type'
|
||||
| 'address_line_1'
|
||||
| 'address_line_2'
|
||||
| 'city'
|
||||
| 'state'
|
||||
| 'postcode'
|
||||
| 'country'
|
||||
|
||||
const ADDRESS_FIELD_KEYS: AddressFieldKey[] = [
|
||||
'address_type',
|
||||
'address_line_1',
|
||||
'address_line_2',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
]
|
||||
|
||||
const addressErrors = reactive<Partial<Record<AddressFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
const ADDRESS_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Rumah', value: 'home' },
|
||||
{ label: 'Pejabat', value: 'office' },
|
||||
{ label: 'Bil', value: 'billing' },
|
||||
]
|
||||
|
||||
const MALAYSIA_STATE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Johor', value: 'Johor' },
|
||||
{ label: 'Kedah', value: 'Kedah' },
|
||||
{ label: 'Kelantan', value: 'Kelantan' },
|
||||
{ label: 'Melaka', value: 'Melaka' },
|
||||
{ label: 'Negeri Sembilan', value: 'Negeri Sembilan' },
|
||||
{ label: 'Pahang', value: 'Pahang' },
|
||||
{ label: 'Perak', value: 'Perak' },
|
||||
{ label: 'Perlis', value: 'Perlis' },
|
||||
{ label: 'Pulau Pinang', value: 'Pulau Pinang' },
|
||||
{ label: 'Sabah', value: 'Sabah' },
|
||||
{ label: 'Sarawak', value: 'Sarawak' },
|
||||
{ label: 'Selangor', value: 'Selangor' },
|
||||
{ label: 'Terengganu', value: 'Terengganu' },
|
||||
{ label: 'Wilayah Persekutuan Kuala Lumpur', value: 'Wilayah Persekutuan Kuala Lumpur' },
|
||||
{ label: 'Wilayah Persekutuan Labuan', value: 'Wilayah Persekutuan Labuan' },
|
||||
{ label: 'Wilayah Persekutuan Putrajaya', value: 'Wilayah Persekutuan Putrajaya' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const addressTypeCollection = createSelectCollection(ADDRESS_TYPE_OPTIONS)
|
||||
const stateCollection = createSelectCollection(MALAYSIA_STATE_OPTIONS)
|
||||
|
||||
const genderValue = ref<string[]>([])
|
||||
const marriageStatusValue = ref<string[]>([])
|
||||
const genderInitial = ref<string[]>([])
|
||||
const marriageStatusInitial = ref<string[]>([])
|
||||
const profileSelectKey = ref(0)
|
||||
|
||||
const addressTypeValue = ref<string[]>([])
|
||||
const stateValue = ref<string[]>([])
|
||||
const addressTypeInitial = ref<string[]>([])
|
||||
const stateInitial = ref<string[]>([])
|
||||
|
||||
function clearProfileFieldError(field: ProfileFieldKey) {
|
||||
delete profileErrors[field]
|
||||
}
|
||||
|
||||
function clearProfileErrors() {
|
||||
for (const field of PROFILE_FIELD_KEYS) {
|
||||
delete profileErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setProfileErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (PROFILE_FIELD_KEYS.includes(field as ProfileFieldKey) && messages[0]) {
|
||||
profileErrors[field as ProfileFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(profileErrors).length > 0
|
||||
}
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
genderValue.value = details.value
|
||||
clearProfileFieldError('gender')
|
||||
}
|
||||
|
||||
function setMarriageStatusValue(details: { value: string[] }) {
|
||||
marriageStatusValue.value = details.value
|
||||
clearProfileFieldError('marriage_status')
|
||||
}
|
||||
|
||||
function clearAddressFieldError(field: AddressFieldKey) {
|
||||
delete addressErrors[field]
|
||||
}
|
||||
|
||||
function clearAddressErrors() {
|
||||
for (const field of ADDRESS_FIELD_KEYS) {
|
||||
delete addressErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setAddressErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (ADDRESS_FIELD_KEYS.includes(field as AddressFieldKey) && messages[0]) {
|
||||
addressErrors[field as AddressFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(addressErrors).length > 0
|
||||
}
|
||||
|
||||
function validateAddressForm(): boolean {
|
||||
clearAddressErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (
|
||||
!addressTypeValue.value[0] ||
|
||||
!labelToApiValue(ADDRESS_TYPE_OPTIONS, addressTypeValue.value[0])
|
||||
) {
|
||||
addressErrors.address_type = 'Jenis alamat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!addressForm.address_line_1.trim()) {
|
||||
addressErrors.address_line_1 = 'Alamat 1 diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!addressForm.city.trim()) {
|
||||
addressErrors.city = 'Bandar diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!labelToApiValue(MALAYSIA_STATE_OPTIONS, stateValue.value[0]) && !addressForm.state.trim()) {
|
||||
addressErrors.state = 'Negeri diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!addressForm.postcode.trim()) {
|
||||
addressErrors.postcode = 'Poskod diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!addressForm.country.trim()) {
|
||||
addressErrors.country = 'Negara diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function setAddressTypeValue(details: { value: string[] }) {
|
||||
addressTypeValue.value = details.value
|
||||
clearAddressFieldError('address_type')
|
||||
addressForm.address_type =
|
||||
labelToApiValue(ADDRESS_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setStateValue(details: { value: string[] }) {
|
||||
stateValue.value = details.value
|
||||
addressForm.state = details.value[0] ?? ''
|
||||
clearAddressFieldError('state')
|
||||
}
|
||||
|
||||
function emptyAddressForm(): AddressPayload {
|
||||
return {
|
||||
address_type: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'Malaysia',
|
||||
}
|
||||
}
|
||||
|
||||
const addressForm = reactive(emptyAddressForm())
|
||||
|
||||
const addressTypeLabel = computed(() =>
|
||||
Object.fromEntries(ADDRESS_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingAddress = computed(() => editingAddressId.value !== null)
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function calculateAge(birthDate: string | null | undefined): number | null {
|
||||
if (!birthDate) return null
|
||||
const birth = new Date(birthDate.slice(0, 10))
|
||||
if (Number.isNaN(birth.getTime())) return null
|
||||
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const monthDiff = today.getMonth() - birth.getMonth()
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--
|
||||
}
|
||||
|
||||
return age >= 0 ? age : null
|
||||
}
|
||||
|
||||
const ageLabel = computed(() => {
|
||||
const age = calculateAge(form.birth_date || authStore.userBirthDate)
|
||||
if (age === null) return '-'
|
||||
return `${age} tahun`
|
||||
})
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function syncProfileSelectValues() {
|
||||
genderValue.value = apiValueToLabel(GENDER_OPTIONS, authStore.user?.gender)
|
||||
genderInitial.value = [...genderValue.value]
|
||||
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, authStore.user?.marriage_status)
|
||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||
profileSelectKey.value++
|
||||
}
|
||||
|
||||
function syncFormFromUser() {
|
||||
const user = authStore.user
|
||||
if (!user) return
|
||||
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
form.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
syncProfileSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveProfile() {
|
||||
saving.value = true
|
||||
clearProfileErrors()
|
||||
|
||||
const gender = labelToApiValue(GENDER_OPTIONS, genderValue.value[0])
|
||||
const marriageStatus = labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0])
|
||||
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim(),
|
||||
gender: gender ?? undefined,
|
||||
marriage_status: marriageStatus ?? undefined,
|
||||
birth_date: form.birth_date || undefined,
|
||||
birth_place: form.birth_place.trim() || undefined,
|
||||
})
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal mengemas kini profil.')
|
||||
}
|
||||
|
||||
authStore.setUserProfile(res.data)
|
||||
syncFormFromUser()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setProfileErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal mengemas kini profil.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncAddressSelectValues() {
|
||||
addressTypeValue.value = apiValueToLabel(ADDRESS_TYPE_OPTIONS, addressForm.address_type)
|
||||
addressTypeInitial.value = [...addressTypeValue.value]
|
||||
stateValue.value = apiValueToLabel(MALAYSIA_STATE_OPTIONS, addressForm.state)
|
||||
stateInitial.value = [...stateValue.value]
|
||||
}
|
||||
|
||||
function resetAddressForm() {
|
||||
Object.assign(addressForm, emptyAddressForm())
|
||||
editingAddressId.value = null
|
||||
clearAddressErrors()
|
||||
syncAddressSelectValues()
|
||||
}
|
||||
|
||||
function formatAddressLine(address: Address) {
|
||||
return [address.address_line_1, address.address_line_2, address.postcode, address.city, address.state, address.country]
|
||||
.filter((part) => part?.trim())
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
loadingAddresses.value = true
|
||||
|
||||
try {
|
||||
const res = await listAddresses()
|
||||
addresses.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan alamat.'),
|
||||
})
|
||||
} finally {
|
||||
loadingAddresses.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditAddress(address: Address) {
|
||||
clearAddressErrors()
|
||||
editingAddressId.value = address.id
|
||||
addressForm.address_type = address.address_type
|
||||
addressForm.address_line_1 = address.address_line_1
|
||||
addressForm.address_line_2 = address.address_line_2 ?? ''
|
||||
addressForm.city = address.city
|
||||
addressForm.state = address.state
|
||||
addressForm.postcode = address.postcode
|
||||
addressForm.country = address.country
|
||||
syncAddressSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveAddress() {
|
||||
if (!validateAddressForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingAddress.value = true
|
||||
const wasEditing = isEditingAddress.value
|
||||
|
||||
const payload: AddressPayload = {
|
||||
address_type: labelToApiValue(ADDRESS_TYPE_OPTIONS, addressTypeValue.value[0]) ?? addressForm.address_type,
|
||||
address_line_1: addressForm.address_line_1.trim(),
|
||||
address_line_2: addressForm.address_line_2?.trim() || undefined,
|
||||
city: addressForm.city.trim(),
|
||||
state: labelToApiValue(MALAYSIA_STATE_OPTIONS, stateValue.value[0]) ?? addressForm.state.trim(),
|
||||
postcode: addressForm.postcode.trim(),
|
||||
country: addressForm.country.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateAddress(editingAddressId.value!, payload)
|
||||
: await createAddress(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan alamat.')
|
||||
}
|
||||
|
||||
await fetchAddresses()
|
||||
resetAddressForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: (wasEditing ? 'Alamat berjaya dikemas kini.' : 'Alamat berjaya ditambah.'),
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setAddressErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan alamat.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingAddress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteAddress(address: Address) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam alamat?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingAddressId.value = address.id
|
||||
|
||||
try {
|
||||
const res = await deleteAddress(address.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam alamat.')
|
||||
}
|
||||
|
||||
if (editingAddressId.value === address.id) {
|
||||
resetAddressForm()
|
||||
}
|
||||
|
||||
await fetchAddresses()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Alamat berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam alamat.'),
|
||||
})
|
||||
} finally {
|
||||
deletingAddressId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authStore.user, syncFormFromUser, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
syncFormFromUser()
|
||||
syncAddressSelectValues()
|
||||
await fetchAddresses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? 'space-y-8' : 'mt-5 space-y-8'">
|
||||
<Box raised="single" class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="onSaveProfile">
|
||||
<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">Maklumat Peribadi</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Kemas kini maklumat peribadi anda.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="profile-name">Nama</FieldLabel>
|
||||
<Input id="profile-name" v-model="form.name" type="text" placeholder="Nama penuh" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-email">E-mel</FieldLabel>
|
||||
<Input id="profile-email" :model-value="authStore.user?.email ?? ''" type="email" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="profile-ic" v-model="form.ic_number" type="text" placeholder="No. kad pengenalan" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
|
||||
placeholder="0123456789" />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot :key="`profile-gender-${profileSelectKey}`" class="w-full" :collection="genderCollection"
|
||||
:default-value="genderInitial" :disabled="saving" @value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!profileErrors.gender">
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="profileErrors.gender">{{ profileErrors.gender }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-age">Umur</FieldLabel>
|
||||
<Input id="profile-age" :model-value="ageLabel" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot :key="`profile-marriage-${profileSelectKey}`" class="w-full"
|
||||
:collection="marriageStatusCollection" :default-value="marriageStatusInitial" :disabled="saving"
|
||||
@value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!profileErrors.marriage_status">
|
||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="profileErrors.marriage_status">{{ profileErrors.marriage_status }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input id="profile-member-number" :model-value="displayValue(authStore.userMemberNumber)" type="text"
|
||||
disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-member-type">Jenis Anggota</FieldLabel>
|
||||
<Input id="profile-member-type" :model-value="displayValue(authStore.userMemberType)" type="text"
|
||||
disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input id="profile-join-date" :model-value="formatDate(authStore.userJoinDate)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="profile-birth-date" v-model="form.birth_date" type="date" :disabled="saving"
|
||||
:aria-invalid="!!profileErrors.birth_date" @input="clearProfileFieldError('birth_date')" />
|
||||
<FieldError v-if="profileErrors.birth_date">{{ profileErrors.birth_date }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input id="profile-birth-place" v-model="form.birth_place" type="text" placeholder="Tempat lahir"
|
||||
:disabled="saving" :aria-invalid="!!profileErrors.birth_place"
|
||||
@input="clearProfileFieldError('birth_place')" />
|
||||
<FieldError v-if="profileErrors.birth_place">{{ profileErrors.birth_place }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Box raised="single" class="p-6">
|
||||
<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">Alamat</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus alamat rumah, pejabat, atau bil anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingAddresses" class="text-sm text-slate-500">
|
||||
Memuatkan alamat...
|
||||
</div>
|
||||
|
||||
<div v-else-if="addresses.length" class="space-y-3">
|
||||
<div v-for="address in addresses" :key="address.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">
|
||||
<Badge look="outline">
|
||||
{{ addressTypeLabel[address.address_type] ?? address.address_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-700">
|
||||
{{ formatAddressLine(address) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingAddressId === address.id" @click="startEditAddress(address)">
|
||||
<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="deletingAddressId === address.id" @click="onDeleteAddress(address)">
|
||||
<Lucide class="mr-2 size-4" :icon="deletingAddressId === address.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingAddressId === address.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 alamat direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveAddress">
|
||||
<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">
|
||||
{{ isEditingAddress ? 'Kemaskini Alamat' : 'Tambah Alamat' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ isEditingAddress ?
|
||||
'Kemas kini maklumat alamat yang dipilih.' : 'Tambah alamat baharu ke profil anda.' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="isEditingAddress" type="button" variant="ghost"
|
||||
class="border border-foreground/15 shadow-none" :disabled="savingAddress" @click="resetAddressForm">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingAddress">
|
||||
{{ savingAddress ? 'Menyimpan...' : isEditingAddress ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Form -->
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jenis Alamat</FieldLabel>
|
||||
<SelectRoot :key="`address-type-${editingAddressId ?? 'new'}`" class="w-full"
|
||||
:collection="addressTypeCollection" :default-value="addressTypeInitial" :disabled="savingAddress"
|
||||
@value-change="setAddressTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!addressErrors.address_type">
|
||||
<SelectValueText placeholder="Pilih jenis alamat" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Alamat</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in addressTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="addressErrors.address_type">{{ addressErrors.address_type }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="address-country">Negara</FieldLabel>
|
||||
<Input id="address-country" v-model="addressForm.country" type="text" placeholder="Negara"
|
||||
:aria-invalid="!!addressErrors.country" @input="clearAddressFieldError('country')" />
|
||||
<FieldError v-if="addressErrors.country">{{ addressErrors.country }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="address-line-1">Alamat 1</FieldLabel>
|
||||
<Input id="address-line-1" v-model="addressForm.address_line_1" type="text"
|
||||
placeholder="Alamat baris pertama" :aria-invalid="!!addressErrors.address_line_1"
|
||||
@input="clearAddressFieldError('address_line_1')" />
|
||||
<FieldError v-if="addressErrors.address_line_1">{{ addressErrors.address_line_1 }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="address-line-2">Alamat 2</FieldLabel>
|
||||
<Input id="address-line-2" v-model="addressForm.address_line_2" type="text"
|
||||
placeholder="Alamat baris kedua (pilihan)" :aria-invalid="!!addressErrors.address_line_2"
|
||||
@input="clearAddressFieldError('address_line_2')" />
|
||||
<FieldError v-if="addressErrors.address_line_2">{{ addressErrors.address_line_2 }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="address-city">Bandar</FieldLabel>
|
||||
<Input id="address-city" v-model="addressForm.city" type="text" placeholder="Bandar"
|
||||
:aria-invalid="!!addressErrors.city" @input="clearAddressFieldError('city')" />
|
||||
<FieldError v-if="addressErrors.city">{{ addressErrors.city }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Negeri</FieldLabel>
|
||||
<SelectRoot :key="`address-state-${editingAddressId ?? 'new'}`" class="w-full"
|
||||
:collection="stateCollection" :default-value="stateInitial" :disabled="savingAddress"
|
||||
@value-change="setStateValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!addressErrors.state">
|
||||
<SelectValueText placeholder="Pilih negeri" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Negeri</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in stateCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="addressErrors.state">{{ addressErrors.state }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="address-postcode">Poskod</FieldLabel>
|
||||
<Input id="address-postcode" v-model="addressForm.postcode" type="text" placeholder="Poskod"
|
||||
:aria-invalid="!!addressErrors.postcode" @input="clearAddressFieldError('postcode')" />
|
||||
<FieldError v-if="addressErrors.postcode">{{ addressErrors.postcode }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,353 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
} from '@/components/ui/menu'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
TooltipPositioner,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h2 class="mr-auto text-lg font-medium">Update Profile</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- BEGIN: Profile Menu -->
|
||||
<div class="col-span-12 flex flex-col-reverse lg:col-span-4 lg:block 2xl:col-span-3">
|
||||
<Box class="mt-5 p-0">
|
||||
<div class="relative flex items-center p-5">
|
||||
<AvatarRoot class="size-12 rounded-full">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[0]!['photos'][0]" alt="avatar" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-4 mr-auto">
|
||||
<div class="text-base font-medium">
|
||||
{{ fakers[0]!['users'][0]!['name'] }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ fakers[0]!['jobs'][0] }}</div>
|
||||
</div>
|
||||
<MenuRoot
|
||||
class="w-auto"
|
||||
:positioning="{
|
||||
placement: 'bottom',
|
||||
}"
|
||||
>
|
||||
<MenuTrigger as-child>
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-64">
|
||||
<div class="font-medium">Export Options</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem value="0">
|
||||
<Lucide icon="Activity" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="1">
|
||||
<Lucide icon="Box" />
|
||||
Indonesia
|
||||
<Badge variant="danger" look="outline" class="ml-auto">10</Badge>
|
||||
</MenuItem>
|
||||
<MenuItem value="2">
|
||||
<Lucide icon="Layout" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="3">
|
||||
<Lucide icon="Sidebar" />
|
||||
Indonesia
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<div class="flex">
|
||||
<Button class="text-xs" type="button" variant="primary" look="outline" size="sm">
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
class="ml-auto text-xs"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
look="outline"
|
||||
size="sm"
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</div>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a
|
||||
class="[&.active]:text-primary active flex items-center [&.active]:font-medium"
|
||||
href=""
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Personal Information
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Account Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Change Password
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> User Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a class="flex items-center" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Email Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Saved Credit Cards
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Social Networks
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> Tax Information
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex border-t border-foreground/10 p-5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15"
|
||||
type="button"
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15 ml-auto"
|
||||
type="button"
|
||||
>
|
||||
New Quick Link
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Profile Menu -->
|
||||
<div class="col-span-12 lg:col-span-8 2xl:col-span-9">
|
||||
<!-- BEGIN: Display Information -->
|
||||
<Box class="p-0 lg:mt-5">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Display Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex flex-col xl:flex-row">
|
||||
<div class="mt-6 flex-1 xl:mt-0">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-1">Display Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-1"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-2">Nearest MRT Station</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-2">
|
||||
<NativeSelectOption value="1">Admiralty</NativeSelectOption>
|
||||
<NativeSelectOption value="2">Aljunied</NativeSelectOption>
|
||||
<NativeSelectOption value="3">Ang Mo Kio</NativeSelectOption>
|
||||
<NativeSelectOption value="4">Bartley</NativeSelectOption>
|
||||
<NativeSelectOption value="5">Beauty World</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field class="mt-3 2xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-3">Postal Code</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-3">
|
||||
<NativeSelectOption value="1"
|
||||
>018906 - 1 STRAITS BOULEVARD SINGA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="2"
|
||||
>018910 - 5A MARINA GARDENS DRIVE...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="3"
|
||||
>018915 - 100A CENTRAL BOULEVARD...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="4"
|
||||
>018925 - 21 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="5"
|
||||
>018926 - 23 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-4">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-4"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12">
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-5">Address</FieldLabel>
|
||||
<Textarea
|
||||
id="update-profile-form-5"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Adress"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="w-28" type="button" variant="primary"> Save </Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div class="mx-auto w-52 xl:ml-6 xl:mr-0">
|
||||
<div class="rounded-xl border-2 border-dashed border-foreground/15 p-5">
|
||||
<div class="image-fit relative mx-auto h-40 cursor-pointer">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="fakers[0]!['photos'][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 items-center justify-center rounded-full text-white [--color:var(--color-danger)]"
|
||||
>
|
||||
<Lucide class="h-4 w-4" icon="X" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPositioner>
|
||||
<TooltipContent>Remove this profile photo?</TooltipContent>
|
||||
</TooltipPositioner>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<div class="relative mx-auto mt-3 cursor-pointer">
|
||||
<Button class="w-full" type="button" variant="primary"> Change Photo </Button>
|
||||
<Input class="absolute left-0 top-0 h-full w-full opacity-0" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Display Information -->
|
||||
<!-- BEGIN: Personal Information -->
|
||||
<Box class="mt-8 p-0">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Personal Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-6">Email</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-6"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['email']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-7">Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-7"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-8">ID Type</FieldLabel>
|
||||
<NativeSelect id="update-profile-form-8">
|
||||
<NativeSelectOption>IC</NativeSelectOption>
|
||||
<NativeSelectOption>FIN</NativeSelectOption>
|
||||
<NativeSelectOption>Passport</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-9">ID Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-9"
|
||||
type="text"
|
||||
value="357821204950001"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field class="mt-3 xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-10">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-10"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-11">Address</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-11"
|
||||
type="text"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-12">Bank Name</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-12">
|
||||
<NativeSelectOption value="1">SBI - STATE BANK OF INDIA</NativeSelectOption>
|
||||
<NativeSelectOption value="2">CITI BANK - CITI BANK</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-13">Bank Account</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-13"
|
||||
type="text"
|
||||
value="DBS Current 011-903573-0"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button class="mr-auto w-28" type="button" variant="primary"> Save </Button>
|
||||
<Button type="button" variant="danger" look="outline">
|
||||
<Lucide class="mr-1 size-4" icon="Trash" /> Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Personal Information -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,16 +7,4 @@ export const profileLayoutRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('./pages/ProfileOverview2.vue'),
|
||||
meta: { title: 'Profil', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'update-profile',
|
||||
name: 'update-profile',
|
||||
component: () => import('./pages/UpdateProfile.vue'),
|
||||
meta: { title: 'Update Profile', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('./pages/ChangePassword.vue'),
|
||||
meta: { title: 'Change Password', module: 'profile' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
AddressApiResponse,
|
||||
AddressPayload,
|
||||
AddressesApiResponse,
|
||||
} from '../types/address.types'
|
||||
|
||||
export async function listAddresses(perPage = 100): Promise<AddressesApiResponse> {
|
||||
const { data } = await api.get<AddressesApiResponse>('/v1/addresses', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAddress(payload: AddressPayload): Promise<AddressApiResponse> {
|
||||
const { data } = await api.post<AddressApiResponse>('/v1/addresses', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAddress(
|
||||
id: string,
|
||||
payload: AddressPayload,
|
||||
): Promise<AddressApiResponse> {
|
||||
const { data } = await api.put<AddressApiResponse>(`/v1/addresses/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAddress(id: string): Promise<AddressApiResponse> {
|
||||
const { data } = await api.delete<AddressApiResponse>(`/v1/addresses/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { BanksApiResponse } from '../types/bank.types'
|
||||
|
||||
export async function listActiveBanks(): Promise<BanksApiResponse> {
|
||||
const { data } = await api.get<BanksApiResponse>('/v1/banks/active')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
BankDetailApiResponse,
|
||||
BankDetailPayload,
|
||||
BankDetailsApiResponse,
|
||||
} from '../types/bankDetail.types'
|
||||
|
||||
export async function listBankDetails(perPage = 100): Promise<BankDetailsApiResponse> {
|
||||
const { data } = await api.get<BankDetailsApiResponse>('/v1/bank_details', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createBankDetail(payload: BankDetailPayload): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.post<BankDetailApiResponse>('/v1/bank_details', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateBankDetail(
|
||||
id: string,
|
||||
payload: BankDetailPayload,
|
||||
): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.put<BankDetailApiResponse>(`/v1/bank_details/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteBankDetail(id: string): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.delete<BankDetailApiResponse>(`/v1/bank_details/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
EmploymentApiResponse,
|
||||
EmploymentPayload,
|
||||
EmploymentsApiResponse,
|
||||
} from '../types/employment.types'
|
||||
|
||||
export async function listEmployments(perPage = 100): Promise<EmploymentsApiResponse> {
|
||||
const { data } = await api.get<EmploymentsApiResponse>('/v1/employments', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createEmployment(payload: EmploymentPayload): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.post<EmploymentApiResponse>('/v1/employments', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateEmployment(
|
||||
id: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.put<EmploymentApiResponse>(`/v1/employments/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteEmployment(id: string): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.delete<EmploymentApiResponse>(`/v1/employments/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { HeirApiResponse, HeirPayload, HeirsApiResponse } from '../types/heir.types'
|
||||
|
||||
export async function listHeirs(perPage = 100): Promise<HeirsApiResponse> {
|
||||
const { data } = await api.get<HeirsApiResponse>('/v1/heirs', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createHeir(payload: HeirPayload): Promise<HeirApiResponse> {
|
||||
const { data } = await api.post<HeirApiResponse>('/v1/heirs', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateHeir(id: string, payload: HeirPayload): Promise<HeirApiResponse> {
|
||||
const { data } = await api.put<HeirApiResponse>(`/v1/heirs/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteHeir(id: string): Promise<HeirApiResponse> {
|
||||
const { data } = await api.delete<HeirApiResponse>(`/v1/heirs/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -31,6 +31,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise<Upda
|
||||
appendIfDefined(formData, 'ic_number', payload.ic_number)
|
||||
appendIfDefined(formData, 'position', payload.position)
|
||||
appendIfDefined(formData, 'phone_number', payload.phone_number)
|
||||
appendIfDefined(formData, 'gender', payload.gender)
|
||||
appendIfDefined(formData, 'marriage_status', payload.marriage_status)
|
||||
appendIfDefined(formData, 'birth_date', payload.birth_date)
|
||||
appendIfDefined(formData, 'birth_place', payload.birth_place)
|
||||
formData.append('image', payload.image!)
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
|
||||
@@ -46,6 +50,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise<Upda
|
||||
if (payload.ic_number?.trim()) body.ic_number = payload.ic_number.trim()
|
||||
if (payload.position?.trim()) body.position = payload.position.trim()
|
||||
if (payload.phone_number?.trim()) body.phone_number = payload.phone_number.trim()
|
||||
if (payload.gender?.trim()) body.gender = payload.gender.trim()
|
||||
if (payload.marriage_status?.trim()) body.marriage_status = payload.marriage_status.trim()
|
||||
if (payload.birth_date?.trim()) body.birth_date = payload.birth_date.trim()
|
||||
if (payload.birth_place?.trim()) body.birth_place = payload.birth_place.trim()
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', body)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Address {
|
||||
id: string
|
||||
user_id: string
|
||||
address_type: string
|
||||
address_line_1: string
|
||||
address_line_2: string | null
|
||||
city: string
|
||||
state: string
|
||||
postcode: string
|
||||
country: string
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface AddressPayload {
|
||||
address_type: string
|
||||
address_line_1: string
|
||||
address_line_2?: string
|
||||
city: string
|
||||
state: string
|
||||
postcode: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface AddressesApiResponse {
|
||||
success: boolean
|
||||
data: Address[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AddressApiResponse {
|
||||
success: boolean
|
||||
data: Address
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface Bank {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
swift_code: string | null
|
||||
is_active: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface BanksApiResponse {
|
||||
success: boolean
|
||||
data: Bank[]
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface BankDetail {
|
||||
id: string
|
||||
user_id: string
|
||||
bank_id: string
|
||||
account_name: string
|
||||
account_number: string
|
||||
account_type: string
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface BankDetailPayload {
|
||||
bank_id: string
|
||||
account_name: string
|
||||
account_number: string
|
||||
account_type: string
|
||||
}
|
||||
|
||||
export interface BankDetailsApiResponse {
|
||||
success: boolean
|
||||
data: BankDetail[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface BankDetailApiResponse {
|
||||
success: boolean
|
||||
data: BankDetail
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Employment {
|
||||
id: string
|
||||
user_id: string
|
||||
company_name: string
|
||||
job_title: string
|
||||
employment_type: string
|
||||
salary: number | string
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
is_current: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface EmploymentPayload {
|
||||
company_name: string
|
||||
job_title: string
|
||||
employment_type: string
|
||||
salary: number
|
||||
start_date: string
|
||||
end_date?: string | null
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
export interface EmploymentsApiResponse {
|
||||
success: boolean
|
||||
data: Employment[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface EmploymentApiResponse {
|
||||
success: boolean
|
||||
data: Employment
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface Heir {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
address: string
|
||||
is_primary: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface HeirPayload {
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
address: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
export interface HeirsApiResponse {
|
||||
success: boolean
|
||||
data: Heir[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface HeirApiResponse {
|
||||
success: boolean
|
||||
data: Heir
|
||||
message?: string
|
||||
}
|
||||
@@ -5,6 +5,10 @@ export interface UpdateProfilePayload {
|
||||
ic_number?: string
|
||||
position?: string
|
||||
phone_number?: string
|
||||
gender?: string
|
||||
marriage_status?: string
|
||||
birth_date?: string
|
||||
birth_place?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
AccordionRoot,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import {
|
||||
groupPermissionsByRoute,
|
||||
resolveDefaultOpenGroups,
|
||||
} from '../utils/groupPermissions'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const props = defineProps<{
|
||||
permissions: RolePermission[]
|
||||
selectedPermissionIds: Set<string>
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkedChange: [permissionId: string, checked: boolean]
|
||||
}>()
|
||||
|
||||
const permissionGroups = computed(() => groupPermissionsByRoute(props.permissions))
|
||||
|
||||
const defaultOpenGroups = computed(() =>
|
||||
resolveDefaultOpenGroups(permissionGroups.value, props.selectedPermissionIds),
|
||||
)
|
||||
|
||||
const accordionKey = computed(
|
||||
() =>
|
||||
`${permissionGroups.value.map((group) => group.key).join('|')}:${props.selectedPermissionIds.size}`,
|
||||
)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
emit('checkedChange', permissionId, checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-h-96 overflow-auto rounded-lg border border-foreground/10 p-3">
|
||||
<div v-if="!permissions.length && !loading" class="py-6 text-center opacity-70">
|
||||
Tiada permissions ditemui
|
||||
</div>
|
||||
|
||||
<AccordionRoot v-else-if="permissionGroups.length" :key="accordionKey" class="w-full"
|
||||
:default-value="defaultOpenGroups">
|
||||
<AccordionItem v-for="group in permissionGroups" :key="group.key" :value="group.key">
|
||||
<AccordionTrigger>{{ group.label }}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<CheckboxRoot v-for="permission in group.permissions" :key="permission.id"
|
||||
:checked="selectedPermissionIds.has(permission.id)" :disabled="loading || disabled"
|
||||
@checked-change="({ checked }) => setPermissionChecked(permission.id, checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>
|
||||
<span class="font-medium">{{ permission.name }}</span>
|
||||
</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listRoles } from '../services/role.service'
|
||||
import type { RoleListItem } from '../types/role.types'
|
||||
|
||||
function getSortValue(item: RoleListItem, key: string): string {
|
||||
if (key === 'permissions') {
|
||||
return item.permissions?.map((permission) => permission.name).join(', ') ?? ''
|
||||
}
|
||||
|
||||
const value = item[key as keyof RoleListItem]
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
export function useRoleList() {
|
||||
const allRoles = ref<RoleListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
const sortedRoles = computed(() => {
|
||||
const activeSort = sortBy.value[0]
|
||||
const sorted = [...allRoles.value]
|
||||
|
||||
if (!activeSort?.key) {
|
||||
return sorted
|
||||
}
|
||||
|
||||
return sorted.sort((left, right) => {
|
||||
const comparison = getSortValue(left, activeSort.key).localeCompare(
|
||||
getSortValue(right, activeSort.key),
|
||||
)
|
||||
|
||||
return activeSort.order === 'desc' ? -comparison : comparison
|
||||
})
|
||||
})
|
||||
|
||||
const roles = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage.value
|
||||
return sortedRoles.value.slice(start, start + itemsPerPage.value)
|
||||
})
|
||||
|
||||
function updatePagination() {
|
||||
const total = sortedRoles.value.length
|
||||
const lastPage = Math.max(1, Math.ceil(total / itemsPerPage.value))
|
||||
const currentPage = Math.min(page.value, lastPage)
|
||||
const from = total === 0 ? null : (currentPage - 1) * itemsPerPage.value + 1
|
||||
const to = total === 0 ? null : Math.min(currentPage * itemsPerPage.value, total)
|
||||
|
||||
applyPagination({
|
||||
current_page: currentPage,
|
||||
per_page: itemsPerPage.value,
|
||||
total,
|
||||
last_page: lastPage,
|
||||
from,
|
||||
to,
|
||||
has_more_pages: currentPage < lastPage,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await listRoles({
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
allRoles.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai peranan.')
|
||||
allRoles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
updatePagination()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
page.value = 1
|
||||
updatePagination()
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
page.value = 1
|
||||
fetchRoles()
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([sortedRoles, page, itemsPerPage], () => {
|
||||
updatePagination()
|
||||
})
|
||||
|
||||
watch(itemsPerPage, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
})
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchRoles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
export const GUARD_OPTIONS: SelectOption[] = [
|
||||
{ label: 'api', value: 'api' },
|
||||
{ label: 'web', value: 'web' },
|
||||
]
|
||||
|
||||
export const CONTEXT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'member', value: 'member' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoleSelectFields(
|
||||
initialGuard: 'api' | 'web' = 'api',
|
||||
initialContext: 'member' | 'admin' = 'member',
|
||||
) {
|
||||
const guardCollection = createSelectCollection(GUARD_OPTIONS)
|
||||
const contextCollection = createSelectCollection(CONTEXT_OPTIONS)
|
||||
|
||||
const guardValue = ref<string[]>([initialGuard])
|
||||
const contextValue = ref<string[]>([initialContext])
|
||||
const guardInitial = ref<string[]>([initialGuard])
|
||||
const contextInitial = ref<string[]>([initialContext])
|
||||
|
||||
function setGuardValue(details: { value: string[] }) {
|
||||
guardValue.value = details.value
|
||||
}
|
||||
|
||||
function setContextValue(details: { value: string[] }) {
|
||||
contextValue.value = details.value
|
||||
}
|
||||
|
||||
function syncFromRole(guard: string, roleContext: string) {
|
||||
const guardName = guard === 'web' ? 'web' : 'api'
|
||||
const contextName = roleContext === 'admin' ? 'admin' : 'member'
|
||||
guardValue.value = [guardName]
|
||||
contextValue.value = [contextName]
|
||||
guardInitial.value = [guardName]
|
||||
contextInitial.value = [contextName]
|
||||
}
|
||||
|
||||
function getGuardName(): 'api' | 'web' {
|
||||
return guardValue.value[0] === 'web' ? 'web' : 'api'
|
||||
}
|
||||
|
||||
function getContext(): 'member' | 'admin' {
|
||||
return contextValue.value[0] === 'admin' ? 'admin' : 'member'
|
||||
}
|
||||
|
||||
return {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardValue,
|
||||
contextValue,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
syncFromRole,
|
||||
getGuardName,
|
||||
getContext,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { roleLayoutRoutes } from './routes'
|
||||
export { roleMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const roleMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ShieldCheck',
|
||||
route_name: 'list-roles',
|
||||
title: 'Senarai Peranan',
|
||||
permission: 'lihat peranan',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import RolePermissionsPicker from '../components/RolePermissionsPicker.vue'
|
||||
import { useRoleSelectFields } from '../composables/useRoleSelectFields'
|
||||
import { createRole, listPermissions } from '../services/role.service'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const fullname = ref('')
|
||||
const permissions = ref<RolePermission[]>([])
|
||||
const selectedPermissionIds = ref<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
getGuardName,
|
||||
getContext,
|
||||
} = useRoleSelectFields()
|
||||
|
||||
const selectedCount = computed(() => selectedPermissionIds.value.size)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
const next = new Set(selectedPermissionIds.value)
|
||||
if (checked) next.add(permissionId)
|
||||
else next.delete(permissionId)
|
||||
selectedPermissionIds.value = next
|
||||
}
|
||||
|
||||
async function fetchPermissions() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await listPermissions()
|
||||
permissions.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai permissions.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await createRole({
|
||||
name: name.value.trim(),
|
||||
fullname: fullname.value.trim(),
|
||||
guard_name: getGuardName(),
|
||||
context: getContext(),
|
||||
permissions: Array.from(selectedPermissionIds.value),
|
||||
})
|
||||
|
||||
successMessage.value = 'Peranan berjaya didaftarkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-roles' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mendaftarkan peranan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPermissions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Daftar Peranan</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-roles' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<Field>
|
||||
<FieldLabel for="role-name">Nama Peranan</FieldLabel>
|
||||
<Input id="role-name" v-model="name" class="w-full" type="text" placeholder="Contoh: ADMIN"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="role-fullname">Nama Penuh Peranan</FieldLabel>
|
||||
<Input id="role-fullname" v-model="fullname" class="w-full" type="text" placeholder="Contoh: Pentadbir Sistem"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Guard</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="guardCollection" :default-value="guardInitial" disabled
|
||||
@value-change="setGuardValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih guard" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Guard</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in guardCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Konteks</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="contextCollection" :default-value="contextInitial"
|
||||
:disabled="loading || saving" @value-change="setContextValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih konteks" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Konteks</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in contextCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="font-medium">Permissions</div>
|
||||
<div class="text-sm opacity-70">{{ selectedCount }} dipilih</div>
|
||||
</div>
|
||||
|
||||
<RolePermissionsPicker :permissions="permissions" :selected-permission-ids="selectedPermissionIds"
|
||||
:loading="loading" :disabled="saving" @checked-change="setPermissionChecked" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="loading || saving">
|
||||
{{ saving ? 'Mendaftar...' : 'Daftar' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import RolePermissionsPicker from '../components/RolePermissionsPicker.vue'
|
||||
import { useRoleSelectFields } from '../composables/useRoleSelectFields'
|
||||
import { getRole, listPermissions, updateRole } from '../services/role.service'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const roleId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const fullname = ref('')
|
||||
const permissions = ref<RolePermission[]>([])
|
||||
const selectedPermissionIds = ref<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
syncFromRole,
|
||||
getGuardName,
|
||||
getContext,
|
||||
} = useRoleSelectFields()
|
||||
|
||||
const selectedCount = computed(() => selectedPermissionIds.value.size)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
const next = new Set(selectedPermissionIds.value)
|
||||
if (checked) next.add(permissionId)
|
||||
else next.delete(permissionId)
|
||||
selectedPermissionIds.value = next
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const [roleResponse, permissionsResponse] = await Promise.all([
|
||||
getRole(roleId.value),
|
||||
listPermissions(),
|
||||
])
|
||||
|
||||
const role = roleResponse.data
|
||||
name.value = role.name
|
||||
fullname.value = role.fullname ?? ''
|
||||
syncFromRole(role.guard_name, role.context)
|
||||
|
||||
permissions.value = permissionsResponse.data
|
||||
selectedPermissionIds.value = new Set(role.permissions?.map((p) => p.id) ?? [])
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat peranan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateRole(roleId.value, {
|
||||
name: name.value.trim(),
|
||||
fullname: fullname.value.trim(),
|
||||
guard_name: getGuardName(),
|
||||
context: getContext(),
|
||||
permissions: Array.from(selectedPermissionIds.value),
|
||||
})
|
||||
|
||||
successMessage.value = 'Peranan berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-roles' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini peranan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Peranan</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-roles' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<Field>
|
||||
<FieldLabel for="role-name">Nama Peranan</FieldLabel>
|
||||
<Input id="role-name" v-model="name" class="w-full" type="text" placeholder="Contoh: ADMIN" disabled />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="role-fullname">Nama Penuh Peranan</FieldLabel>
|
||||
<Input id="role-fullname" v-model="fullname" class="w-full" type="text" placeholder="Contoh: Pentadbir Sistem"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Guard</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="guardCollection" :default-value="guardInitial"
|
||||
disabled @value-change="setGuardValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih guard" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Guard</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in guardCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Konteks</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="contextCollection" :default-value="contextInitial"
|
||||
:disabled="loading || saving" @value-change="setContextValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih konteks" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Konteks</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in contextCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="font-medium">Permissions</div>
|
||||
<div class="text-sm opacity-70">{{ selectedCount }} dipilih</div>
|
||||
</div>
|
||||
|
||||
<RolePermissionsPicker :permissions="permissions" :selected-permission-ids="selectedPermissionIds"
|
||||
:loading="loading" :disabled="saving" @checked-change="setPermissionChecked" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="loading || saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationRoot,
|
||||
PaginationItem,
|
||||
PaginationPrevTrigger,
|
||||
PaginationNextTrigger,
|
||||
PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useRoleList } from '../composables/useRoleList'
|
||||
import { deleteRole as deleteRoleService } from '../services/role.service'
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const roleToDelete = ref<string | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const { roles, loading, error, search, page, pagination, fetchRoles } = useRoleList()
|
||||
|
||||
function handlePageChange(details: { page: number }) {
|
||||
page.value = details.page
|
||||
}
|
||||
|
||||
function contextBadgeVariant(context: string) {
|
||||
if (context === 'admin') return 'success'
|
||||
if (context === 'member') return 'pending'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function goToCreateRole() {
|
||||
router.push({ name: 'create-role' })
|
||||
}
|
||||
|
||||
function goToEditRole(roleId: string) {
|
||||
router.push({ name: 'edit-role', params: { id: roleId } })
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(roleId: string) {
|
||||
roleToDelete.value = roleId
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!roleToDelete.value || deleting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteRoleService(roleToDelete.value)
|
||||
deleteConfirmationOpen.value = false
|
||||
roleToDelete.value = null
|
||||
await fetchRoles()
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus peranan.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Senarai Peranan</h2>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<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">
|
||||
<!-- Search -->
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="relative w-56">
|
||||
<Input v-model="search" class="w-56 pr-10" type="search" placeholder="Cari peranan..." />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<Button class="mt-3 sm:mt-0 sm:ml-auto" look="outline" variant="primary" :disabled="loading"
|
||||
@click="goToCreateRole">
|
||||
Tambah Peranan
|
||||
</Button>
|
||||
</div>
|
||||
<!-- BEGIN: Data List -->
|
||||
<div v-for="role in roles" :key="role.id" class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<Box class="p-0">
|
||||
<div class="p-5">
|
||||
<div class="rounded-lg border border-foreground/10 bg-foreground/2 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
<div class="mt-1 truncate text-xs opacity-70">
|
||||
{{ role.fullname || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge class="shrink-0" look="outline" :variant="contextBadgeVariant(role.context)">
|
||||
{{ role.context }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 opacity-70">
|
||||
<div class="flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="Shield" />
|
||||
Guard: {{ role.guard_name }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="KeyRound" />
|
||||
Permissions: {{ role.permissions?.length ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center border-t border-foreground/8 p-5 lg:justify-end">
|
||||
<a class="mr-3 flex items-center" href="#" @click.prevent="goToEditRole(role.id)">
|
||||
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" /> Edit
|
||||
</a>
|
||||
<a class="text-danger flex items-center" href="#"
|
||||
@click.prevent="openDeleteConfirmation(role.id)">
|
||||
<Lucide class="mr-1 h-4 w-4" icon="Trash" /> Delete
|
||||
</a>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Data List -->
|
||||
<div v-if="!loading && !roles.length" class="col-span-12">
|
||||
<Box class="p-6 text-center opacity-70">Tiada rekod ditemui</Box>
|
||||
</div>
|
||||
<!-- BEGIN: Pagination -->
|
||||
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot :count="pagination.total" :page="page" :siblingCount="1"
|
||||
:onPageChange="handlePageChange" 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>
|
||||
</div>
|
||||
<!-- END: Pagination -->
|
||||
</div>
|
||||
<!-- BEGIN: Delete Confirmation Modal -->
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Adakah anda yakin?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu menghapus rekod ini? <br />
|
||||
Proses ini tidak boleh dibatalkan.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting"> Cancel </DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Delete Confirmation Modal -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const roleLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'list-roles',
|
||||
name: 'list-roles',
|
||||
component: () => import('./pages/RoleList.vue'),
|
||||
meta: { title: 'List Roles', module: 'role', permission: 'lihat peranan' },
|
||||
},
|
||||
{
|
||||
path: 'roles/create',
|
||||
name: 'create-role',
|
||||
component: () => import('./pages/RoleCreate.vue'),
|
||||
meta: { title: 'Create Role', module: 'role', permission: 'tambah peranan' },
|
||||
},
|
||||
{
|
||||
path: 'roles/:id/edit',
|
||||
name: 'edit-role',
|
||||
component: () => import('./pages/RoleEdit.vue'),
|
||||
meta: { title: 'Edit Role', module: 'role', permission: 'kemaskini peranan' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { CreateRolePayload, ListRolesParams, RoleListItem, RolePermission } from '../types/role.types'
|
||||
|
||||
type RolesApiResponse = {
|
||||
success: boolean
|
||||
data: RoleListItem[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type RoleApiResponse = {
|
||||
success: boolean
|
||||
data: RoleListItem
|
||||
message?: string
|
||||
}
|
||||
|
||||
type PermissionsApiResponse = {
|
||||
success: boolean
|
||||
data: RolePermission[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listRoles(params?: ListRolesParams): Promise<RolesApiResponse> {
|
||||
const { data } = await api.get<RolesApiResponse>('/v1/roles', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load roles')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRole(id: string): Promise<RoleApiResponse> {
|
||||
const { data } = await api.get<RoleApiResponse>(`/v1/roles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createRole(payload: CreateRolePayload): Promise<RoleApiResponse> {
|
||||
const { data } = await api.post<RoleApiResponse>('/v1/roles', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to create role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
id: string,
|
||||
payload: CreateRolePayload,
|
||||
): Promise<RoleApiResponse> {
|
||||
const { data } = await api.put<RoleApiResponse>(`/v1/roles/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to update role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string): Promise<RoleApiResponse> {
|
||||
const { data } = await api.delete<RoleApiResponse>(`/v1/roles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to delete role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listPermissions(): Promise<PermissionsApiResponse> {
|
||||
const { data } = await api.get<PermissionsApiResponse>('/v1/permissions')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load permissions')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface RolePermission {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
route_name: string | null
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
fullname: string | null
|
||||
context: string
|
||||
permissions?: RolePermission[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface RoleListItem {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
fullname: string | null
|
||||
context: string
|
||||
permissions?: RolePermission[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ListRolesParams {
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface CreateRolePayload {
|
||||
name: string
|
||||
fullname: string
|
||||
guard_name: 'api' | 'web'
|
||||
context: 'member' | 'admin'
|
||||
permissions?: string[]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user