DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { activityLayoutRoutes } from './routes'
export { activityMenu } from './menu'
+10
View File
@@ -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>
+44
View File
@@ -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
}
+8
View File
@@ -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>
+26 -4
View File
@@ -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">
+1 -1
View File
@@ -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>
+201
View File
@@ -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>
+12
View File
@@ -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'
}
+28
View File
@@ -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,
}
}
+3
View File
@@ -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>
+516
View File
@@ -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>
+75 -131
View File
@@ -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">
+821
View File
@@ -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>
-12
View File
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { roleLayoutRoutes } from './routes'
export { roleMenu } from './menu'
+10
View File
@@ -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',
},
]
+196
View File
@@ -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>
+210
View File
@@ -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>
+195
View File
@@ -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>
+22
View File
@@ -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
}
+40
View File
@@ -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[]
}
@@ -0,0 +1,57 @@
import type { RolePermission } from '../types/role.types'
export interface PermissionGroup {
key: string
label: string
permissions: RolePermission[]
}
function toGroupKey(routeName: string): string {
return routeName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function groupPermissionsByRoute(
permissions: RolePermission[],
): PermissionGroup[] {
const groups = new Map<string, RolePermission[]>()
for (const permission of permissions) {
const label = permission.route_name?.trim() || 'Lain-lain'
const bucket = groups.get(label) ?? []
bucket.push(permission)
groups.set(label, bucket)
}
return Array.from(groups.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([label, items]) => ({
key: toGroupKey(label) || 'lain-lain',
label,
permissions: [...items].sort((left, right) =>
left.name.localeCompare(right.name),
),
}))
}
export function resolveDefaultOpenGroups(
groups: PermissionGroup[],
selectedPermissionIds: Set<string>,
): string[] {
const openGroups = groups
.filter((group) =>
group.permissions.some((permission) =>
selectedPermissionIds.has(permission.id),
),
)
.map((group) => group.key)
if (openGroups.length) {
return openGroups
}
return groups[0] ? [groups[0].key] : []
}
@@ -0,0 +1,99 @@
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 { listDeletedUsers } from '../services/user.service'
import type { UserListItem } from '../types/user.types'
export function useDeletedUserList(options?: { autoWatchFilters?: boolean; autoFetchOnMount?: boolean }) {
const autoWatchFilters = options?.autoWatchFilters ?? true
const autoFetchOnMount = options?.autoFetchOnMount ?? true
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: 'deleted_at', order: 'desc' }])
const page = ref(1)
const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
async function fetchUsers(requestPage = page.value) {
loading.value = true
error.value = null
try {
const activeSort = sortBy.value[0]
const data = await listDeletedUsers({
page: requestPage,
per_page: itemsPerPage.value,
sort_by: activeSort?.key ?? 'deleted_at',
sort_order: activeSort?.order ?? 'desc',
search: search.value.trim() || undefined,
status: statusFilter.value.trim() || undefined,
})
users.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna yang dipadam.')
users.value = []
} finally {
loading.value = false
}
}
function handleSortUpdate(value: SortConfig[]) {
sortBy.value = value
fetchUsers(1)
}
const debouncedSearch = debounce(() => {
fetchUsers(1)
}, 400)
if (autoWatchFilters) {
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)
}
})
if (autoFetchOnMount) {
onMounted(() => {
fetchUsers(1)
})
}
return {
users,
loading,
error,
search,
statusFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
fetchUsers,
}
}
@@ -28,7 +28,7 @@ function userCanImpersonate(): boolean {
)
}
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
function isImpersonateTargetVisible(target: UserListItem, currentUserId?: string): boolean {
if (!currentUserId || target.id === currentUserId) {
return false
}
@@ -36,6 +36,14 @@ function targetCanBeImpersonated(target: UserListItem, currentUserId?: string):
return !target.roles?.some((role) => role.name === DEVELOPER_ROLE)
}
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
if (!isImpersonateTargetVisible(target, currentUserId)) {
return false
}
return target.status === 'active'
}
export function useImpersonate() {
const authStore = useAuthStore()
const router = useRouter()
@@ -44,10 +52,26 @@ export function useImpersonate() {
const impersonating = computed(() => authStore.isImpersonating)
const canImpersonate = computed(() => userCanImpersonate())
function showImpersonateButton(target: UserListItem): boolean {
return canImpersonate.value && isImpersonateTargetVisible(target, authStore.user?.id)
}
function canImpersonateUser(target: UserListItem): boolean {
return canImpersonate.value && targetCanBeImpersonated(target, authStore.user?.id)
}
function impersonateButtonTitle(target: UserListItem): string {
if (impersonating.value) {
return 'Anda sedang menyamar pengguna'
}
if (target.status !== 'active') {
return 'Hanya pengguna aktif boleh disamar'
}
return 'Menyamar sebagai pengguna'
}
async function refreshImpersonationStatus() {
await authStore.refreshImpersonationStatus()
}
@@ -136,7 +160,9 @@ export function useImpersonate() {
return {
canImpersonate,
showImpersonateButton,
canImpersonateUser,
impersonateButtonTitle,
impersonating,
loading,
refreshImpersonationStatus,
@@ -85,5 +85,6 @@ export function useUserList() {
itemsPerPage,
pagination,
handleSortUpdate,
fetchUsers,
}
}
+1
View File
@@ -5,5 +5,6 @@ export const userMenu: Menu[] = [
icon: 'Users',
route_name: 'list-users',
title: 'Senarai Pengguna',
permission: 'lihat pengguna',
},
]
@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import type { UserBankDetail, UserDetail } from '../types/user.types'
defineProps<{
user: UserDetail
embedded?: boolean
}>()
const ACCOUNT_TYPE_LABEL: Record<string, string> = {
Saving: 'Simpanan',
Current: 'Semasa',
}
function bankLabel(bankDetail: UserBankDetail): string {
if (bankDetail.bank) {
return `${bankDetail.bank.name} (${bankDetail.bank.code})`
}
return bankDetail.bank_id
}
</script>
<template>
<div :class="embedded ? '' : 'mt-5'">
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Akaun Bank</h3>
<p class="mt-1 text-sm text-slate-500">Senarai akaun bank pengguna.</p>
</div>
<div v-if="user.bank_details?.length" class="space-y-3">
<div
v-for="bankDetail in user.bank_details"
:key="bankDetail.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ bankLabel(bankDetail) }}</span>
<Badge look="outline">
{{ ACCOUNT_TYPE_LABEL[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>
<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>
</Box>
</div>
</template>
+441
View File
@@ -0,0 +1,441 @@
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as select from '@zag-js/select'
import { AlertRoot, AlertTitle, AlertDescription } 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 {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { createUser } from '../services/user.service'
type SelectOption = { label: string; value: string }
const STATUS_OPTIONS: SelectOption[] = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
]
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 MEMBER_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Anggota', value: 'Anggota' },
{ label: 'Pesara', value: 'Pesara' },
]
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
}
const statusCollection = createSelectCollection(STATUS_OPTIONS)
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
const statusValue = ref<string[]>(['Pending'])
const genderValue = ref<string[]>([])
const marriageStatusValue = ref<string[]>([])
const memberTypeValue = ref<string[]>([])
const statusInitial = ref<string[]>(['Pending'])
function setStatusValue(details: { value: string[] }) {
statusValue.value = details.value
}
function setGenderValue(details: { value: string[] }) {
genderValue.value = details.value
}
function setMarriageStatusValue(details: { value: string[] }) {
marriageStatusValue.value = details.value
}
function setMemberTypeValue(details: { value: string[] }) {
memberTypeValue.value = details.value
}
const router = useRouter()
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const form = reactive({
name: '',
email: '',
ic_number: '',
position: '',
phone_number: '',
member_number: '',
join_date: '',
birth_date: '',
birth_place: '',
})
function requireSelectValue(label: string | undefined, fieldName: string): string {
if (!label) {
throw new Error(`${fieldName} diperlukan.`)
}
return label
}
async function handleSubmit() {
saving.value = true
error.value = null
successMessage.value = null
try {
const status = labelToApiValue(STATUS_OPTIONS, statusValue.value[0])
const gender = labelToApiValue(GENDER_OPTIONS, genderValue.value[0])
const marriageStatus = labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0])
const memberType = labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0])
if (!status) throw new Error('Status pengguna diperlukan.')
requireSelectValue(genderValue.value[0], 'Jantina')
requireSelectValue(marriageStatusValue.value[0], 'Status perkahwinan')
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
if (!form.member_number) throw new Error('Nombor anggota diperlukan.')
if (!form.join_date) throw new Error('Tarikh sertai diperlukan.')
if (!form.birth_date) throw new Error('Tarikh lahir diperlukan.')
if (!form.birth_place.trim()) throw new Error('Tempat lahir diperlukan.')
await createUser({
name: form.name.trim(),
email: form.email.trim(),
ic_number: form.ic_number.trim(),
position: form.position.trim(),
phone_number: form.phone_number.trim() || null,
status,
gender: gender!,
marriage_status: marriageStatus!,
member_number: Number(form.member_number),
member_type: memberType!,
join_date: form.join_date,
birth_date: form.birth_date,
birth_place: form.birth_place.trim(),
})
successMessage.value = 'Pengguna berjaya didaftarkan.'
setTimeout(() => {
router.push({ name: 'list-users' })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal mendaftarkan pengguna.')
} finally {
saving.value = false
}
}
const formDisabled = computed(() => saving.value)
</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 Pengguna</h2>
<Button
look="outline"
variant="secondary"
type="button"
:disabled="saving"
@click="router.push({ name: 'list-users' })"
>
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">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-name">Nama</FieldLabel>
<Input
id="user-name"
v-model="form.name"
class="w-full"
type="text"
placeholder="Nama penuh"
:disabled="formDisabled"
required
/>
</Field>
<Field>
<FieldLabel for="user-email">Emel</FieldLabel>
<Input
id="user-email"
v-model="form.email"
class="w-full"
type="email"
placeholder="emel@example.com"
:disabled="formDisabled"
required
/>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
<Input
id="user-ic"
v-model="form.ic_number"
class="w-full"
type="text"
placeholder="Nombor kad pengenalan"
:disabled="formDisabled"
required
/>
</Field>
<Field>
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
<Input
id="user-phone"
v-model="form.phone_number"
class="w-full"
type="text"
placeholder="Nombor telefon"
:disabled="formDisabled"
/>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-position">Jawatan</FieldLabel>
<Input
id="user-position"
v-model="form.position"
class="w-full"
type="text"
placeholder="Jawatan"
:disabled="formDisabled"
required
/>
</Field>
<Field>
<FieldLabel>Status Pengguna</FieldLabel>
<SelectRoot
class="w-full"
:collection="statusCollection"
:default-value="statusInitial"
:disabled="formDisabled"
@value-change="setStatusValue"
>
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem
v-for="item in statusCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel>Jantina</FieldLabel>
<SelectRoot
class="w-full"
:collection="genderCollection"
:disabled="formDisabled"
@value-change="setGenderValue"
>
<SelectControl>
<SelectTrigger>
<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>
</Field>
<Field>
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot
class="w-full"
:collection="marriageStatusCollection"
:disabled="formDisabled"
@value-change="setMarriageStatusValue"
>
<SelectControl>
<SelectTrigger>
<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>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
<Input
id="user-member-number"
v-model="form.member_number"
class="w-full"
type="number"
min="0"
placeholder="Nombor anggota"
:disabled="formDisabled"
required
/>
</Field>
<Field>
<FieldLabel>Jenis Anggota</FieldLabel>
<SelectRoot
class="w-full"
:collection="memberTypeCollection"
:disabled="formDisabled"
@value-change="setMemberTypeValue"
>
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jenis anggota" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
<SelectItem
v-for="item in memberTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
<Input
id="user-join-date"
v-model="form.join_date"
class="w-full"
type="date"
:disabled="formDisabled"
required
/>
</Field>
<Field>
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
<Input
id="user-birth-date"
v-model="form.birth_date"
class="w-full"
type="date"
:disabled="formDisabled"
required
/>
</Field>
</div>
<Field>
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
<Input
id="user-birth-place"
v-model="form.birth_place"
class="w-full"
type="text"
placeholder="Tempat lahir"
:disabled="formDisabled"
required
/>
</Field>
<div class="flex items-center justify-end gap-2 pt-2">
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
{{ saving ? 'Mendaftar...' : 'Daftar' }}
</Button>
</div>
</form>
</Box>
</div>
</template>
+374
View File
@@ -0,0 +1,374 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as select from '@zag-js/select'
import { AlertRoot, AlertTitle, AlertDescription } 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 {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getUser, updateUser } from '../services/user.service'
type SelectOption = { label: string; value: string }
const STATUS_OPTIONS: SelectOption[] = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
]
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 MEMBER_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Anggota', value: 'Anggota' },
{ label: 'Pesara', value: 'Pesara' },
]
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] : []
}
const statusCollection = createSelectCollection(STATUS_OPTIONS)
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
const statusValue = ref<string[]>([])
const genderValue = ref<string[]>([])
const marriageStatusValue = ref<string[]>([])
const memberTypeValue = ref<string[]>([])
const statusInitial = ref<string[]>([])
const genderInitial = ref<string[]>([])
const marriageStatusInitial = ref<string[]>([])
const memberTypeInitial = ref<string[]>([])
function setStatusValue(details: { value: string[] }) {
statusValue.value = details.value
}
function setGenderValue(details: { value: string[] }) {
genderValue.value = details.value
}
function setMarriageStatusValue(details: { value: string[] }) {
marriageStatusValue.value = details.value
}
function setMemberTypeValue(details: { value: string[] }) {
memberTypeValue.value = details.value
}
const router = useRouter()
const route = useRoute()
const userId = 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 email = ref('')
const form = reactive({
name: '',
ic_number: '',
position: '',
phone_number: '',
member_number: '',
join_date: '',
birth_date: '',
birth_place: '',
})
function toDateInputValue(value: string | null | undefined): string {
if (!value) return ''
return value.slice(0, 10)
}
function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
email.value = user.email ?? ''
form.name = user.name ?? ''
form.ic_number = user.ic_number ?? ''
form.position = user.position ?? ''
form.phone_number = user.phone_number ?? ''
form.member_number = user.member_number != null ? String(user.member_number) : ''
form.join_date = toDateInputValue(user.join_date)
form.birth_date = toDateInputValue(user.birth_date)
form.birth_place = user.birth_place ?? ''
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
genderValue.value = apiValueToLabel(GENDER_OPTIONS, user.gender)
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, user.marriage_status)
memberTypeValue.value = apiValueToLabel(MEMBER_TYPE_OPTIONS, user.member_type)
statusInitial.value = [...statusValue.value]
genderInitial.value = [...genderValue.value]
marriageStatusInitial.value = [...marriageStatusValue.value]
memberTypeInitial.value = [...memberTypeValue.value]
}
async function fetchUser() {
loading.value = true
error.value = null
successMessage.value = null
try {
const response = await getUser(userId.value)
syncFormFromUser(response.data)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat pengguna.')
} finally {
loading.value = false
}
}
async function handleSubmit() {
saving.value = true
error.value = null
successMessage.value = null
try {
await updateUser(userId.value, {
name: form.name.trim(),
ic_number: form.ic_number.trim(),
position: form.position.trim(),
phone_number: form.phone_number.trim() || null,
status: labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
gender: labelToApiValue(GENDER_OPTIONS, genderValue.value[0]),
marriage_status: labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0]),
member_number: form.member_number ? Number(form.member_number) : null,
member_type: labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0]),
join_date: form.join_date || null,
birth_date: form.birth_date || null,
birth_place: form.birth_place.trim() || null,
})
successMessage.value = 'Pengguna berjaya dikemaskini.'
setTimeout(() => {
router.push({ name: 'list-users' })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal mengemaskini pengguna.')
} finally {
saving.value = false
}
}
const formDisabled = computed(() => loading.value || saving.value)
onMounted(() => {
fetchUser()
})
</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 Pengguna</h2>
<Button look="outline" variant="secondary" type="button" :disabled="saving"
@click="router.push({ name: 'list-users' })">
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">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-name">Nama</FieldLabel>
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
:disabled="formDisabled" required />
</Field>
<Field>
<FieldLabel for="user-email">Emel</FieldLabel>
<Input id="user-email" v-model="email" class="w-full" type="email" disabled />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" placeholder="Nombor kad pengenalan"
:disabled="formDisabled" required />
</Field>
<Field>
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
:disabled="formDisabled" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-position">Jawatan</FieldLabel>
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
:disabled="formDisabled" />
</Field>
<Field>
<FieldLabel>Status Pengguna</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="statusCollection" :default-value="statusInitial"
:disabled="formDisabled" @value-change="setStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel>Jantina</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="genderCollection" :default-value="genderInitial"
:disabled="formDisabled" @value-change="setGenderValue">
<SelectControl>
<SelectTrigger>
<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>
</Field>
<Field>
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="marriageStatusCollection"
:default-value="marriageStatusInitial" :disabled="formDisabled" @value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger>
<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>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
placeholder="Nombor anggota" :disabled="formDisabled" />
</Field>
<Field>
<FieldLabel>Jenis Anggota</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="memberTypeCollection"
:default-value="memberTypeInitial" :disabled="formDisabled" @value-change="setMemberTypeValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jenis anggota" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled" />
</Field>
<Field>
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
</Field>
</div>
<Field>
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
:disabled="formDisabled" />
</Field>
<div class="flex items-center justify-end gap-2 pt-2">
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
{{ saving ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</form>
</Box>
</div>
</template>
@@ -0,0 +1,85 @@
<script lang="ts" setup>
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import type { Employment } from '@/modules/profile/types/employment.types'
import type { UserDetail } from '../types/user.types'
defineProps<{
user: UserDetail
embedded?: boolean
}>()
const EMPLOYMENT_TYPE_LABEL: Record<string, string> = {
Permanent: 'Tetap',
Contract: 'Kontrak',
Internship: 'Latihan Industri',
Freelance: 'Freelance',
}
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
}
</script>
<template>
<div :class="embedded ? '' : 'mt-5'">
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
<p class="mt-1 text-sm text-slate-500">Senarai pekerjaan pengguna.</p>
</div>
<div v-if="user.employments?.length" class="space-y-3">
<div
v-for="employment in user.employments"
:key="employment.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span>
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
<Badge look="outline">
{{ EMPLOYMENT_TYPE_LABEL[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>
<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>
</Box>
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import type { UserDetail } from '../types/user.types'
defineProps<{
user: UserDetail
embedded?: boolean
}>()
</script>
<template>
<div :class="embedded ? '' : 'mt-5'">
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
<p class="mt-1 text-sm text-slate-500">Senarai pewaris pengguna.</p>
</div>
<div v-if="user.heirs?.length" class="space-y-3">
<div
v-for="heir in user.heirs"
:key="heir.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div 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>
<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>
</Box>
</div>
</template>
+503 -16
View File
@@ -1,15 +1,78 @@
<script lang="ts" setup>
import { onMounted } from 'vue'
import { Search, HatGlasses } from '@lucide/vue'
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import debounce from 'lodash/debounce'
import { Search, HatGlasses, SquarePen, Trash2, Eye, Shield, RotateCcw } from '@lucide/vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Lucide } from '@/components/ui/lucide'
import DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listRoles } from '@/modules/role/services/role.service'
import type { RoleListItem } from '@/modules/role/types/role.types'
import { useImpersonate } from '../composables/useImpersonate'
import { useDeletedUserList } from '../composables/useDeletedUserList'
import { useUserList } from '../composables/useUserList'
import {
assignUserRoles as assignUserRolesService,
deleteUser as deleteUserService,
restoreUser as restoreUserService,
} from '../services/user.service'
import type { UserRole, UserListItem } from '../types/user.types'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
type StatusFilterChip = {
label: string
value: string
variant: BadgeVariants['variant']
}
const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
{ label: 'Semua', value: '', variant: 'ghost' },
{ label: 'Active', value: 'active', variant: 'success' },
{ label: 'Inactive', value: 'inactive', variant: 'danger' },
{ label: 'Pending', value: 'pending', variant: 'pending' },
]
const router = useRouter()
const { hasPermission } = usePermissions()
const deleteConfirmationOpen = ref(false)
const userToDelete = ref<UserListItem | null>(null)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const assignRolesOpen = ref(false)
const userToAssignRoles = ref<UserListItem | null>(null)
const availableRoles = ref<RoleListItem[]>([])
const selectedRoleIds = ref<Set<string>>(new Set())
const loadingRoles = ref(false)
const savingRoles = ref(false)
const assignRolesError = ref<string | null>(null)
const restoreConfirmationOpen = ref(false)
const userToRestore = ref<UserListItem | null>(null)
const restoring = ref(false)
const restoreError = ref<string | null>(null)
function goToEditUser(userId: string) {
router.push({ name: 'edit-user', params: { id: userId } })
}
function goToCreateUser() {
router.push({ name: 'create-user' })
}
function goToViewUser(userId: string) {
router.push({ name: 'view-user', params: { id: userId } })
}
function formatUserRoles(roles: UserRole[] | undefined): string {
return roles?.map((role) => role.name).join(', ') || '-'
@@ -21,18 +84,166 @@ function statusBadgeVariant(status: string) {
return 'pending'
}
function isStatusFilterActive(value: string) {
return statusFilter.value === value
}
function setStatusFilter(value: string) {
statusFilter.value = value
}
function formatDeletedAt(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
function openDeleteConfirmation(user: UserListItem) {
userToDelete.value = user
deleteError.value = null
deleteConfirmationOpen.value = true
}
async function confirmDelete() {
if (!userToDelete.value || deleting.value) {
return
}
deleting.value = true
deleteError.value = null
try {
await deleteUserService(userToDelete.value.id)
deleteConfirmationOpen.value = false
userToDelete.value = null
await Promise.all([fetchUsers(page.value), fetchDeletedUsers(deletedPage.value)])
} catch (err) {
deleteError.value = getApiErrorMessage(err, 'Gagal mengpadam akaun pengguna.')
} finally {
deleting.value = false
}
}
async function loadAvailableRoles() {
if (availableRoles.value.length) {
return
}
loadingRoles.value = true
assignRolesError.value = null
try {
const response = await listRoles()
availableRoles.value = response.data.filter((role) => role.guard_name === 'api')
} catch (err) {
assignRolesError.value = getApiErrorMessage(err, 'Gagal memuatkan senarai peranan.')
availableRoles.value = []
} finally {
loadingRoles.value = false
}
}
async function openAssignRolesDialog(user: UserListItem) {
userToAssignRoles.value = user
selectedRoleIds.value = new Set(user.roles?.map((role) => role.id) ?? [])
assignRolesError.value = null
assignRolesOpen.value = true
await loadAvailableRoles()
}
function setRoleChecked(roleId: string, checked: boolean) {
const next = new Set(selectedRoleIds.value)
if (checked) {
next.add(roleId)
} else {
next.delete(roleId)
}
selectedRoleIds.value = next
}
async function confirmAssignRoles() {
if (!userToAssignRoles.value || savingRoles.value) {
return
}
if (!selectedRoleIds.value.size) {
assignRolesError.value = 'Sila pilih sekurang-kurangnya satu peranan.'
return
}
savingRoles.value = true
assignRolesError.value = null
try {
await assignUserRolesService(
userToAssignRoles.value.id,
Array.from(selectedRoleIds.value),
)
assignRolesOpen.value = false
userToAssignRoles.value = null
await fetchUsers(page.value)
} catch (err) {
assignRolesError.value = getApiErrorMessage(err, 'Gagal menetapkan peranan pengguna.')
} finally {
savingRoles.value = false
}
}
function openRestoreConfirmation(user: UserListItem) {
userToRestore.value = user
restoreError.value = null
restoreConfirmationOpen.value = true
}
async function confirmRestore() {
if (!userToRestore.value || restoring.value) {
return
}
restoring.value = true
restoreError.value = null
try {
await restoreUserService(userToRestore.value.id)
restoreConfirmationOpen.value = false
userToRestore.value = null
await Promise.all([fetchUsers(page.value), fetchDeletedUsers(deletedPage.value)])
} catch (err) {
restoreError.value = getApiErrorMessage(err, 'Gagal memulihkan akaun pengguna.')
} finally {
restoring.value = false
}
}
const headers: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Emel', key: 'email', sortable: true },
{ title: 'Jawatan', key: 'position', sortable: true },
{ title: 'No. Anggota', key: 'member_number', sortable: true, align: 'center' },
{
title: 'Peranan',
key: 'roles',
sortable: false,
exportValue: (item) => formatUserRoles(item.roles),
},
{ title: 'Status Pengguna', key: 'status', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
const deletedHeaders: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Emel', key: 'email', sortable: true },
{ title: 'Jawatan', key: 'position', sortable: true },
{ title: 'No. Anggota', key: 'member_number', sortable: true, align: 'center' },
{
title: 'Peranan',
key: 'roles',
sortable: false,
exportValue: (item) => formatUserRoles(item.roles),
},
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Dipadam Pada', key: 'deleted_at', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
@@ -41,15 +252,43 @@ const {
loading,
error,
search,
statusFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
fetchUsers,
} = useUserList()
const {
users: deletedUsers,
loading: deletedLoading,
error: deletedError,
search: deletedSearch,
statusFilter: deletedStatusFilter,
sortBy: deletedSortBy,
page: deletedPage,
itemsPerPage: deletedItemsPerPage,
pagination: deletedPagination,
handleSortUpdate: handleDeletedSortUpdate,
fetchUsers: fetchDeletedUsers,
} = useDeletedUserList({ autoWatchFilters: false, autoFetchOnMount: false })
const debouncedDeletedFetch = debounce(() => {
fetchDeletedUsers(1)
}, 400)
watch([search, statusFilter], () => {
deletedSearch.value = search.value
deletedStatusFilter.value = statusFilter.value
debouncedDeletedFetch()
})
const {
showImpersonateButton,
canImpersonateUser,
impersonateButtonTitle,
impersonating,
loading: impersonateLoading,
refreshImpersonationStatus,
@@ -58,11 +297,18 @@ const {
onMounted(() => {
refreshImpersonationStatus()
deletedSearch.value = search.value
deletedStatusFilter.value = statusFilter.value
fetchDeletedUsers(1)
})
</script>
<template>
<div class="w-full space-y-6">
<div>
<h2 class="text-lg font-medium">Senarai Pengguna</h2>
<p class="mt-1 text-sm opacity-70">Urus dan semak pengguna koperasi.</p>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
@@ -72,16 +318,50 @@ onMounted(() => {
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
@update:sort-by="handleSortUpdate">
<template #toolbar>
<div class="relative w-full max-w-md">
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
aria-hidden="true" />
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..." class="w-full pl-9"
aria-label="Search users" />
<div class="flex w-full flex-col gap-3">
<div class="flex w-full flex-wrap items-center gap-3">
<div class="relative w-full max-w-md flex-1">
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
aria-hidden="true" />
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..."
class="w-full pl-9" aria-label="Search users" />
</div>
<Button v-if="hasPermission('daftar pengguna baru')" type="button" variant="primary" look="outline"
@click="goToCreateUser">
Daftar Pengguna
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Status:</span>
<Badge
v-for="chip in STATUS_FILTER_CHIPS"
:key="chip.value || 'all'"
:variant="chip.variant"
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'"
class="capitalize"
role="button"
tabindex="0"
:aria-pressed="isStatusFilterActive(chip.value)"
@click="setStatusFilter(chip.value)"
@keydown.enter="setStatusFilter(chip.value)"
>
{{ chip.label }}
</Badge>
</div>
</div>
</template>
<template #item.name="{ item }">
<span class="font-medium">{{ item.name }}</span>
<div class="flex items-center gap-2">
<!-- profile image -->
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
<div v-else class="size-10 rounded-full bg-gray-200 flex items-center justify-center">
<span class="text-gray-500">{{ item.name.charAt(0) }}</span>
</div>
<!-- name -->
<span class="font-medium">{{ item.name }}</span>
</div>
</template>
<template #item.email="{ item }">
@@ -92,20 +372,227 @@ onMounted(() => {
{{ formatUserRoles(item.roles) }}
</template>
<template #item.member_number="{ item }">
<span class="text-center">{{ item.member_number }}</span>
</template>
<!-- centre align status -->
<template #item.status="{ item }">
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize">
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
{{ item.status }}
</Badge>
</template>
<template #item.actions="{ item }">
<Button v-if="canImpersonateUser(item as UserListItem)" type="button" variant="outline" size="sm"
class="gap-1.5 bg-orange-500 text-white" :disabled="impersonateLoading || impersonating"
:title="impersonating ? 'Anda sedang menyamar pengguna' : 'Menyamar sebagai pengguna'"
@click="impersonateUser(item as UserListItem)">
<HatGlasses class="size-4" aria-hidden="true" />
</Button>
<div class="flex items-center">
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'"
@click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan
<Shield class="size-4" aria-hidden="true" />
</Button>
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" size="sm"
class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)">
<Eye class="size-4" aria-hidden="true" />
</Button>
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles"
:title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)">
<SquarePen class="size-4" aria-hidden="true" />
</Button>
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" size="sm"
class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting"
@click="openDeleteConfirmation(item as UserListItem)">
<Trash2 class="size-4" aria-hidden="true" />
</Button>
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" size="sm"
class="bg-orange-600 text-white disabled:opacity-50"
:disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating"
:title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)">
<HatGlasses class="size-4" aria-hidden="true" />
</Button>
</div>
</template>
</DataTable>
<div class="space-y-4 border-t border-foreground/10 pt-8">
<div>
<h3 class="text-lg font-medium">Pengguna Dipadam</h3>
<p class="mt-1 text-sm opacity-70">Pengguna yang telah dipadam dan boleh dipulihkan.</p>
</div>
<AlertRoot v-if="deletedError" variant="danger">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ deletedError }}</AlertDescription>
</AlertRoot>
<DataTable
:headers="deletedHeaders"
:items="deletedUsers"
:loading="deletedLoading"
:pagination="deletedPagination"
:current-sort="deletedSortBy"
show-pagination
exportable
export-file-name="deleted-users"
v-model:page="deletedPage"
v-model:items-per-page="deletedItemsPerPage"
@update:sort-by="handleDeletedSortUpdate"
>
<template #item.name="{ item }">
<div class="flex items-center gap-2">
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
<div v-else class="flex size-10 items-center justify-center rounded-full bg-gray-200">
<span class="text-gray-500">{{ item.name.charAt(0) }}</span>
</div>
<span class="font-medium">{{ item.name }}</span>
</div>
</template>
<template #item.email="{ item }">
<span class="lowercase">{{ item.email }}</span>
</template>
<template #item.roles="{ item }">
{{ formatUserRoles(item.roles) }}
</template>
<template #item.member_number="{ item }">
<span class="text-center">{{ item.member_number ?? '-' }}</span>
</template>
<template #item.status="{ item }">
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
{{ item.status }}
</Badge>
</template>
<template #item.deleted_at="{ item }">
{{ formatDeletedAt(item.deleted_at) }}
</template>
<template #item.actions="{ item }">
<div class="flex items-center">
<Button
v-if="hasPermission('padam akaun pengguna')"
type="button"
variant="outline"
size="sm"
class="bg-blue-600 text-white disabled:opacity-50"
:disabled="restoring"
title="Pulihkan pengguna"
@click="openRestoreConfirmation(item as UserListItem)"
>
<RotateCcw class="size-4" aria-hidden="true" />
Pulihkan
</Button>
</div>
</template>
</DataTable>
</div>
<DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)">
<DialogContent>
<div class="p-5">
<div class="text-center text-2xl font-medium">Tetapkan Peranan</div>
<div v-if="userToAssignRoles" class="mt-2 text-center opacity-70">
Pilih peranan untuk
<span class="font-medium">{{ userToAssignRoles.name }}</span>
</div>
<div v-if="assignRolesError" class="mt-4 text-center text-sm text-danger">
{{ assignRolesError }}
</div>
<div class="mt-5 max-h-80 overflow-auto rounded-lg border border-foreground/10 p-3">
<div v-if="loadingRoles" class="py-6 text-center opacity-70">
Memuatkan peranan...
</div>
<div v-else-if="!availableRoles.length" class="py-6 text-center opacity-70">
Tiada peranan ditemui
</div>
<div v-else class="grid grid-cols-1 gap-2">
<CheckboxRoot v-for="role in availableRoles" :key="role.id" :checked="selectedRoleIds.has(role.id)"
:disabled="savingRoles" @checked-change="({ checked }) => setRoleChecked(role.id, checked === true)">
<CheckboxControl />
<CheckboxLabel>
<span class="font-medium">{{ role.name }}</span>
<span v-if="role.fullname" class="ml-2 opacity-70">({{ role.fullname }})</span>
</CheckboxLabel>
</CheckboxRoot>
</div>
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-24" :disabled="savingRoles">
Batal
</DialogCloseTrigger>
<Button class="w-24" type="button" variant="primary" look="outline" :disabled="savingRoles || loadingRoles"
@click="confirmAssignRoles">
{{ savingRoles ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
<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
<span v-if="userToDelete" class="font-medium">{{ userToDelete.name }}</span>?
<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">
Batal
</DialogCloseTrigger>
<Button class="w-24" type="button" variant="danger" look="outline" :disabled="deleting"
@click="confirmDelete">
{{ deleting ? 'Menghapus...' : 'Hapus' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
<DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)">
<DialogContent>
<div class="p-5 text-center">
<Lucide class="mx-auto mt-3 size-16 stroke-1 text-primary" icon="RotateCcw" />
<div class="mt-5 text-2xl font-medium">Pulihkan pengguna?</div>
<div class="mt-2 opacity-70">
Adakah anda mahu memulihkan
<span v-if="userToRestore" class="font-medium">{{ userToRestore.name }}</span>?
</div>
<div v-if="restoreError" class="mt-4 text-sm text-danger">
{{ restoreError }}
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
Batal
</DialogCloseTrigger>
<Button
class="w-24"
type="button"
variant="primary"
look="outline"
:disabled="restoring"
@click="confirmRestore"
>
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</div>
</template>
@@ -0,0 +1,153 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { Address } from '@/modules/profile/types/address.types'
import type { UserDetail } from '../types/user.types'
const props = defineProps<{
user: UserDetail
embedded?: boolean
}>()
const ADDRESS_TYPE_LABEL: Record<string, string> = {
home: 'Rumah',
office: 'Pejabat',
billing: 'Bil',
}
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
const statusLabel = computed(() => {
const status = props.user.status
if (!status) return '-'
return status.charAt(0).toUpperCase() + status.slice(1)
})
const roleNames = computed(() => props.user.roles?.map((role) => role.name).join(', ') || '-')
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return value.slice(0, 10)
}
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(', ')
}
</script>
<template>
<div :class="embedded ? 'space-y-8' : 'mt-5 space-y-8'">
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Maklumat Peribadi</h3>
<p class="mt-1 text-sm text-slate-500">Paparan maklumat pengguna (baca sahaja).</p>
</div>
<FieldGroup>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="view-user-name">Nama</FieldLabel>
<Input id="view-user-name" :model-value="displayValue(user.name)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-email">E-mel</FieldLabel>
<Input id="view-user-email" :model-value="displayValue(user.email)" type="email" disabled />
</Field>
<Field>
<FieldLabel for="view-user-ic">No. Kad Pengenalan</FieldLabel>
<Input id="view-user-ic" :model-value="displayValue(user.ic_number)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-phone">No. Telefon</FieldLabel>
<Input id="view-user-phone" :model-value="displayValue(user.phone_number)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-position">Jawatan</FieldLabel>
<Input id="view-user-position" :model-value="displayValue(user.position)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-status">Status</FieldLabel>
<Input id="view-user-status" :model-value="statusLabel" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-gender">Jantina</FieldLabel>
<Input id="view-user-gender" :model-value="displayValue(user.gender)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-marriage">Status Perkahwinan</FieldLabel>
<Input
id="view-user-marriage"
:model-value="displayValue(user.marriage_status)"
type="text"
disabled
/>
</Field>
<Field>
<FieldLabel for="view-user-member-number">Nombor Anggota</FieldLabel>
<Input
id="view-user-member-number"
:model-value="displayValue(user.member_number)"
type="text"
disabled
/>
</Field>
<Field>
<FieldLabel for="view-user-member-type">Jenis Anggota</FieldLabel>
<Input id="view-user-member-type" :model-value="displayValue(user.member_type)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-join-date">Tarikh Sertai</FieldLabel>
<Input id="view-user-join-date" :model-value="formatDate(user.join_date)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-birth-date">Tarikh Lahir</FieldLabel>
<Input id="view-user-birth-date" :model-value="formatDate(user.birth_date)" type="text" disabled />
</Field>
<Field class="md:col-span-2">
<FieldLabel for="view-user-birth-place">Tempat Lahir</FieldLabel>
<Input id="view-user-birth-place" :model-value="displayValue(user.birth_place)" type="text" disabled />
</Field>
<Field class="md:col-span-2">
<FieldLabel for="view-user-roles">Peranan</FieldLabel>
<Input id="view-user-roles" :model-value="roleNames" type="text" disabled />
</Field>
</div>
</FieldGroup>
</Box>
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Alamat</h3>
<p class="mt-1 text-sm text-slate-500">Senarai alamat pengguna.</p>
</div>
<div v-if="user.addresses?.length" class="space-y-3">
<div
v-for="address in user.addresses"
:key="address.id"
class="rounded-lg border border-foreground/10 p-4"
>
<Badge look="outline">
{{ ADDRESS_TYPE_LABEL[address.address_type] ?? address.address_type }}
</Badge>
<p class="mt-2 text-sm text-slate-700">{{ formatAddressLine(address) }}</p>
</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>
</Box>
</div>
</template>
@@ -0,0 +1,169 @@
<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 { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getUser } from '../services/user.service'
import type { UserDetail } from '../types/user.types'
import UserProfileTab from './UserProfileTab.vue'
import UserEmploymentTab from './UserEmploymentTab.vue'
import UserBankDetailTab from './UserBankDetailTab.vue'
import UserHeirTab from './UserHeirTab.vue'
const router = useRouter()
const route = useRoute()
const userId = computed(() => String(route.params.id ?? ''))
const loading = ref(false)
const error = ref<string | null>(null)
const user = ref<UserDetail | null>(null)
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
const statusLabel = computed(() => {
const status = user.value?.status
if (!status) return '-'
return status.charAt(0).toUpperCase() + status.slice(1)
})
const roleNames = computed(() =>
user.value?.roles?.map((role) => role.name).join(', ') || '-',
)
const avatarFallback = computed(() => {
const name = user.value?.name?.trim()
if (!name) return '--'
return name.slice(0, 2).toUpperCase()
})
async function fetchUser() {
loading.value = true
error.value = null
try {
const response = await getUser(userId.value)
user.value = response.data
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil pengguna.')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchUser()
})
</script>
<template>
<div>
<div class="flex flex-wrap items-center gap-3">
<h2 class="mr-auto text-lg font-medium">Profil {{ user?.name }} - {{ user?.member_number }}</h2>
<Button look="outline" variant="secondary" type="button" @click="router.push({ name: 'list-users' })">
Kembali
</Button>
</div>
<AlertRoot v-if="error" class="mt-5" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<div v-if="loading" class="mt-5 opacity-70">Memuatkan profil...</div>
<TabsRoot v-else-if="user" defaultValue="1">
<Box raised="single" class="mt-5 p-0">
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
<AvatarImage v-if="user.image_url" :src="user.image_url" :alt="user.name" />
</AvatarRoot>
<div class="ml-5">
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
{{ displayValue(user.name) }}
</div>
<div class="opacity-70">{{ roleNames }}</div>
</div>
</div>
<div
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
<div class="flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Mail" />
{{ displayValue(user.email) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Phone" />
{{ displayValue(user.phone_number) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="IdCard" />
{{ displayValue(user.ic_number) }}
</div>
</div>
</div>
<div
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
<div class="grid grid-cols-3 gap-5">
<div class="text-center">
<div class="truncate text-xl font-medium">{{ user.roles?.length ?? 0 }}</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="truncate text-xl font-medium capitalize">
{{ displayValue(user.member_type) }}
</div>
<div class="opacity-70">Jenis Anggota</div>
</div>
</div>
</div>
</div>
<div class="px-5 py-4">
<TabsList class="mb-0 w-full flex justify-between">
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="1">
<Lucide class="mr-2 size-4" icon="User" /> Profil
</TabsTrigger>
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="5">
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
</TabsTrigger>
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="3">
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
</TabsTrigger>
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="6">
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
</TabsTrigger>
</TabsList>
</div>
</Box>
<TabsContent value="1" class="mt-8">
<UserProfileTab :user="user" embedded />
</TabsContent>
<TabsContent value="5" class="mt-8">
<UserEmploymentTab :user="user" embedded />
</TabsContent>
<TabsContent value="3" class="mt-8">
<UserBankDetailTab :user="user" embedded />
</TabsContent>
<TabsContent value="6" class="mt-8">
<UserHeirTab :user="user" embedded />
</TabsContent>
</TabsRoot>
</div>
</template>
+19 -1
View File
@@ -5,6 +5,24 @@ export const userLayoutRoutes: RouteRecordRaw[] = [
path: 'list-users',
name: 'list-users',
component: () => import('./pages/UserList.vue'),
meta: { title: 'List Users', module: 'user' },
meta: { title: 'List Users', module: 'user', permission: 'lihat pengguna' },
},
{
path: 'users/create',
name: 'create-user',
component: () => import('./pages/UserCreate.vue'),
meta: { title: 'Create User', module: 'user', permission: 'daftar pengguna baru' },
},
{
path: 'users/:id/edit',
name: 'edit-user',
component: () => import('./pages/UserEdit.vue'),
meta: { title: 'Edit User', module: 'user', permission: 'kemaskini pengguna' },
},
{
path: 'users/:id/profile',
name: 'view-user',
component: () => import('./pages/UserProfileView.vue'),
meta: { title: 'View User Profile', module: 'user', permission: 'lihat pengguna' },
},
]
+96 -1
View File
@@ -1,6 +1,19 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
import type { ListUsersParams, UserListItem } from '../types/user.types'
import type {
CreateUserPayload,
ListDeletedUsersParams,
ListUsersParams,
UpdateUserPayload,
UserDetail,
UserListItem,
} from '../types/user.types'
type UserApiResponse = {
success: boolean
data: UserDetail
message?: string
}
export async function listUsers(
params: ListUsersParams,
@@ -15,3 +28,85 @@ export async function listUsers(
return data
}
export async function listDeletedUsers(
params: ListDeletedUsersParams,
): Promise<PaginatedApiResponse<UserListItem>> {
const { data } = await api.get<PaginatedApiResponse<UserListItem>>('/v1/users/deleted', {
params,
})
if (!data.success) {
throw new Error(data.message ?? 'Failed to load deleted users')
}
return data
}
export async function restoreUser(id: string): Promise<UserApiResponse> {
const { data } = await api.post<UserApiResponse>(`/v1/users/${id}/restore`)
if (!data.success) {
throw new Error(data.message ?? 'Failed to restore user')
}
return data
}
export async function getUser(id: string): Promise<UserApiResponse> {
const { data } = await api.get<UserApiResponse>(`/v1/users/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Failed to load user')
}
return data
}
export async function createUser(payload: CreateUserPayload): Promise<UserApiResponse> {
const { data } = await api.post<UserApiResponse>('/v1/users', payload)
if (!data.success) {
throw new Error(data.message ?? 'Failed to create user')
}
return data
}
export async function updateUser(
id: string,
payload: UpdateUserPayload,
): Promise<UserApiResponse> {
const { data } = await api.patch<UserApiResponse>(`/v1/users/${id}`, payload)
if (!data.success) {
throw new Error(data.message ?? 'Failed to update user')
}
return data
}
export async function deleteUser(id: string): Promise<UserApiResponse> {
const { data } = await api.delete<UserApiResponse>(`/v1/users/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Failed to delete user')
}
return data
}
export async function assignUserRoles(
id: string,
roleIds: string[],
): Promise<UserApiResponse> {
const { data } = await api.post<UserApiResponse>(`/v1/users/${id}/roles`, {
roles: roleIds,
})
if (!data.success) {
throw new Error(data.message ?? 'Failed to assign roles')
}
return data
}
+75
View File
@@ -1,9 +1,19 @@
import type { Address } from '@/modules/profile/types/address.types'
import type { Bank } from '@/modules/profile/types/bank.types'
import type { BankDetail } from '@/modules/profile/types/bankDetail.types'
import type { Employment } from '@/modules/profile/types/employment.types'
import type { Heir } from '@/modules/profile/types/heir.types'
export interface UserRole {
id: string
name: string
guard_name: string
}
export interface UserBankDetail extends BankDetail {
bank?: Bank | null
}
export interface UserListItem {
id: string
name: string
@@ -13,7 +23,63 @@ export interface UserListItem {
phone_number: string
image_url: string | null
status: string
deleted_at?: string | null
roles: UserRole[]
member_number?: number | null
}
export interface UserDetail {
id: string
name: string
email: string
ic_number: string
position: string
phone_number: string | null
image_url: string | null
status: string
gender: string | null
marriage_status: string | null
member_number: number | null
member_type: string | null
join_date: string | null
birth_date: string | null
birth_place: string | null
roles: UserRole[]
addresses?: Address[]
employments?: Employment[]
bank_details?: UserBankDetail[]
heirs?: Heir[]
}
export interface UpdateUserPayload {
name: string
ic_number: string
position: string
phone_number?: string | null
status?: string | null
gender?: string | null
marriage_status?: string | null
member_number?: number | null
member_type?: string | null
join_date?: string | null
birth_date?: string | null
birth_place?: string | null
}
export interface CreateUserPayload {
name: string
email: string
ic_number: string
position: string
phone_number?: string | null
status: string
gender: string
marriage_status: string
member_number: number
member_type: string
join_date: string
birth_date: string
birth_place: string
}
export interface ListUsersParams {
@@ -24,3 +90,12 @@ export interface ListUsersParams {
search?: string
status?: string
}
export interface ListDeletedUsersParams {
page: number
per_page: number
sort_by: string
sort_order: string
search?: string
status?: string
}