DONE: feedback module, use permission name for notification

This commit is contained in:
ISMAIL MASSERAN
2026-07-14 12:02:37 +08:00
parent 324b7facf1
commit 52ffd3393a
60 changed files with 3083 additions and 26 deletions
+2
View File
@@ -6,6 +6,7 @@ import { activityMenu } from '@/modules/activity'
import { dashboardMenu } from '@/modules/dashboard/menu'
import { externalSystemMenu } from '@/modules/external-system/menu'
import { activityLogMenu } from '@/modules/activity-log/menu'
import { feedbackMenu } from '@/modules/feedback'
export type { Menu }
@@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [
'Teknologi Maklumat',
...roleMenu,
...activityLogMenu,
...feedbackMenu,
'Pentadbiran',
...membershipApplicationMenu,
...userMenu,
+2
View File
@@ -12,6 +12,7 @@ import {
login,
resolvePostAuthRoute,
} from '@/modules/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { useAuthStore } from '@/stores/auth'
import illustrationUrl from '@/assets/images/logo.svg'
@@ -70,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
'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',
]">
<HelpdeskFab />
<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%]',
+2
View File
@@ -16,6 +16,7 @@ import {
verifyPhoneVerificationOtp,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg'
@@ -283,6 +284,7 @@ const stepLabelClass = (stepId: number) => {
'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',
]">
<HelpdeskFab />
<div :class="[
'relative p-3 sm:px-8',
'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%]',
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { Lucide } from '@/components/ui/lucide'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
/** Vertical offset from vertical center, in rem (matches Layout side tabs). */
offsetRem?: number
/** Open feedback form in a new tab instead of navigating in place. */
openInNewTab?: boolean
}>(),
{
offsetRem: 3.5,
openInNewTab: true,
},
)
const router = useRouter()
function openHelpdesk(event: MouseEvent) {
event.preventDefault()
const { href } = router.resolve({ name: 'feedback-submit' })
if (props.openInNewTab) {
window.open(href, '_blank', 'noopener,noreferrer')
return
}
router.push({ name: 'feedback-submit' })
}
</script>
<template>
<button type="button" aria-label="Helpdesk" :style="{
top: `calc(50% + ${offsetRem}rem)`,
['--color' as string]: 'var(--color-primary)',
}" :class="[
'group fixed right-0 z-50 flex h-12 cursor-pointer items-center overflow-hidden rounded-l-full border border-(--color)/50 bg-background/80 shadow-lg transition-all',
'w-14 hover:w-44',
'before:absolute before:inset-0 before:bg-(--color)/20',
]" @click="openHelpdesk">
<span class="relative z-10 flex items-center gap-2 px-5">
<Lucide icon="MessageCircle" />
<span :class="[
'whitespace-nowrap text-sm',
'max-w-0 overflow-hidden transition-[max-width] duration-200 ease-out',
'group-hover:max-w-40',
]">
Maklum Balas
</span>
</span>
</button>
</template>
@@ -0,0 +1,104 @@
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 { listFeedback } from '../services/feedback.service'
import type {
FeedbackListItem,
FeedbackPriority,
FeedbackStatus,
FeedbackType,
} from '../types/feedback.types'
export function useFeedbackList() {
const items = ref<FeedbackListItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const typeFilter = ref<FeedbackType | ''>('')
const statusFilter = ref<FeedbackStatus | ''>('')
const priorityFilter = ref<FeedbackPriority | ''>('')
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
const page = ref(1)
const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
async function fetchItems(requestPage = page.value) {
loading.value = true
error.value = null
try {
const activeSort = sortBy.value[0]
const data = await listFeedback({
page: requestPage,
per_page: itemsPerPage.value,
sort_by: activeSort?.key ?? 'created_at',
sort_order: activeSort?.order ?? 'desc',
search: search.value.trim() || undefined,
type: typeFilter.value || undefined,
status: statusFilter.value || undefined,
priority: priorityFilter.value || undefined,
})
items.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai maklum balas.')
items.value = []
} finally {
loading.value = false
}
}
function handleSortUpdate(value: SortConfig[]) {
sortBy.value = value
fetchItems(1)
}
const debouncedSearch = debounce(() => {
fetchItems(1)
}, 400)
watch(search, () => {
debouncedSearch()
})
watch([typeFilter, statusFilter, priorityFilter], () => {
fetchItems(1)
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchItems(nextPage)
}
})
watch(itemsPerPage, (nextValue, previousValue) => {
if (nextValue !== previousValue) {
fetchItems(1)
}
})
onMounted(() => {
fetchItems(1)
})
return {
items,
loading,
error,
search,
typeFilter,
statusFilter,
priorityFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
fetchItems,
}
}
+22
View File
@@ -0,0 +1,22 @@
export { feedbackPublicRoutes, feedbackLayoutRoutes } from './routes'
export { feedbackMenu } from './menu'
export { default as HelpdeskFab } from './components/HelpdeskFab.vue'
export {
submitFeedback,
listFeedback,
getFeedback,
updateFeedback,
deleteFeedback,
getMyFeedback,
getFeedbackStatistics,
} from './services/feedback.service'
export type {
Feedback,
FeedbackFormState,
FeedbackListItem,
FeedbackStatus,
FeedbackType,
FeedbackPriority,
SubmitFeedbackPayload,
UpdateFeedbackPayload,
} from './types/feedback.types'
+10
View File
@@ -0,0 +1,10 @@
import type { Menu } from '@/core/types/menu'
export const feedbackMenu: Menu[] = [
{
icon: 'MessageCircle',
route_name: 'list-feedback',
title: 'Maklum Balas',
permission: 'lihat maklum balas',
},
]
@@ -0,0 +1,486 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import { CircleAlert, CircleCheck, Play } 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 { Field, FieldLabel } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import {
deleteFeedback,
fetchFeedbackDocument,
getFeedback,
updateFeedback,
} from '../services/feedback.service'
import {
FEEDBACK_STATUS_OPTIONS,
feedbackPriorityLabel,
feedbackStatusLabel,
feedbackTypeLabel,
type Feedback,
type FeedbackDocument,
type FeedbackStatus,
} from '../types/feedback.types'
type SelectOption = { label: string; value: string }
type MediaPreviewKind = 'image' | 'video'
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 route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
const feedback = ref<Feedback | null>(null)
const loading = ref(true)
const saving = ref(false)
const deleting = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const mediaObjectUrls = ref<Record<string, string>>({})
const mediaLoadErrors = ref<Record<string, boolean>>({})
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewDocument = ref<FeedbackDocument | null>(null)
const previewKind = ref<MediaPreviewKind>('image')
const previewUrl = ref<string | null>(null)
const adminForm = reactive({
status: 'open' as FeedbackStatus,
admin_notes: '',
})
const canUpdate = computed(() => hasPermission('kemaskini maklum balas'))
const canDelete = computed(() => hasPermission('padam maklum balas'))
const statusCollection = createSelectCollection(FEEDBACK_STATUS_OPTIONS)
const statusInitial = computed(() => valueToLabel(FEEDBACK_STATUS_OPTIONS, adminForm.status))
function setStatusValue(details: { value: string[] }) {
adminForm.status = labelToValue(FEEDBACK_STATUS_OPTIONS, details.value[0]) as FeedbackStatus
}
function formatDate(value: string | null | undefined): 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 revokeUrl(url: string | null | undefined) {
if (url) {
window.URL.revokeObjectURL(url)
}
}
function clearMediaObjectUrls() {
Object.values(mediaObjectUrls.value).forEach((url) => revokeUrl(url))
mediaObjectUrls.value = {}
mediaLoadErrors.value = {}
}
function revokePreviewUrl() {
if (previewUrl.value && !Object.values(mediaObjectUrls.value).includes(previewUrl.value)) {
revokeUrl(previewUrl.value)
}
previewUrl.value = null
}
async function loadMediaDocument(document: FeedbackDocument): Promise<string | null> {
if (mediaObjectUrls.value[document.id]) {
return mediaObjectUrls.value[document.id] ?? null
}
if (!feedback.value) return null
try {
const blob = await fetchFeedbackDocument(
feedback.value.id,
document.id,
document.mime_type,
)
const objectUrl = window.URL.createObjectURL(blob)
mediaObjectUrls.value = {
...mediaObjectUrls.value,
[document.id]: objectUrl,
}
return objectUrl
} catch {
mediaLoadErrors.value = {
...mediaLoadErrors.value,
[document.id]: true,
}
return null
}
}
async function preloadAttachments(item: Feedback) {
clearMediaObjectUrls()
const documents = [...(item.images ?? []), ...(item.videos ?? [])]
await Promise.all(documents.map((document) => loadMediaDocument(document)))
}
async function openMediaPreview(document: FeedbackDocument, kind: MediaPreviewKind) {
previewDocument.value = document
previewKind.value = kind
previewOpen.value = true
previewLoading.value = true
revokePreviewUrl()
const objectUrl = await loadMediaDocument(document)
previewUrl.value = objectUrl
previewLoading.value = false
if (!objectUrl) {
error.value = 'Gagal memuatkan fail lampiran.'
closeMediaPreview()
}
}
function closeMediaPreview() {
previewOpen.value = false
previewDocument.value = null
previewLoading.value = false
revokePreviewUrl()
}
async function fetchDetail() {
loading.value = true
error.value = null
closeMediaPreview()
try {
const id = String(route.params.id)
feedback.value = await getFeedback(id)
adminForm.status = feedback.value.status
adminForm.admin_notes = feedback.value.admin_notes ?? ''
await preloadAttachments(feedback.value)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklum balas.')
feedback.value = null
clearMediaObjectUrls()
} finally {
loading.value = false
}
}
async function handleSave() {
if (!feedback.value || saving.value || !canUpdate.value) return
saving.value = true
error.value = null
successMessage.value = null
try {
const response = await updateFeedback(feedback.value.id, {
status: adminForm.status,
admin_notes: adminForm.admin_notes.trim() || null,
})
feedback.value = response.data
adminForm.status = response.data.status
adminForm.admin_notes = response.data.admin_notes ?? ''
successMessage.value = response.message ?? 'Maklum balas berjaya dikemas kini.'
await preloadAttachments(response.data)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal mengemas kini maklum balas.')
} finally {
saving.value = false
}
}
async function handleDelete() {
if (!feedback.value || deleting.value || !canDelete.value) return
if (!window.confirm('Padam maklum balas ini?')) return
deleting.value = true
error.value = null
try {
await deleteFeedback(feedback.value.id)
router.push({ name: 'list-feedback' })
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memadam maklum balas.')
} finally {
deleting.value = false
}
}
watch(
() => route.params.id,
() => {
fetchDetail()
},
)
onMounted(fetchDetail)
onUnmounted(() => {
closeMediaPreview()
clearMediaObjectUrls()
})
</script>
<template>
<div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<div class="mr-auto">
<Button look="outline" size="sm" @click="router.push({ name: 'list-feedback' })">
Kembali
</Button>
<h2 class="mt-3 text-lg font-medium">Butiran Maklum Balas</h2>
<p class="mt-1 text-sm opacity-70">Semak dan kemas kini status laporan.</p>
</div>
<div v-if="canDelete" class="flex gap-2">
<Button look="outline" variant="danger" :disabled="deleting" @click="handleDelete">
{{ deleting ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<CircleAlert class="size-4" />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<AlertRoot v-if="successMessage" class="mt-6" variant="success">
<CircleCheck class="size-4" />
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
<AlertCloseTrigger @click="successMessage = null" />
</AlertRoot>
<div v-if="loading" class="mt-8 opacity-70">Memuatkan...</div>
<template v-else-if="feedback">
<div class="mt-6 grid gap-6 lg:grid-cols-3">
<Box class="space-y-4 p-6 lg:col-span-2">
<div class="flex flex-wrap items-center gap-2">
<Badge look="outline">{{ feedbackTypeLabel(feedback.type) }}</Badge>
<Badge look="outline">{{ feedbackPriorityLabel(feedback.priority) }}</Badge>
<Badge look="outline">{{ feedbackStatusLabel(feedback.status) }}</Badge>
</div>
<div>
<h3 class="text-xl font-semibold">{{ feedback.title }}</h3>
<p class="mt-2 whitespace-pre-wrap text-sm opacity-80">{{ feedback.description }}</p>
</div>
<div v-if="feedback.page_url" class="text-sm">
<span class="opacity-60">URL:</span>
<a :href="feedback.page_url" target="_blank" rel="noopener" class="ml-2 text-primary underline">
{{ feedback.page_url }}
</a>
</div>
<div v-if="feedback.steps_to_reproduce" class="text-sm">
<div class="font-medium">Langkah menghasilkan semula</div>
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.steps_to_reproduce }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div v-if="feedback.expected_behavior" class="text-sm">
<div class="font-medium">Kelakuan dijangka</div>
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.expected_behavior }}</p>
</div>
<div v-if="feedback.actual_behavior" class="text-sm">
<div class="font-medium">Kelakuan sebenar</div>
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.actual_behavior }}</p>
</div>
</div>
<div v-if="feedback.additional_notes" class="text-sm">
<div class="font-medium">Nota tambahan</div>
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.additional_notes }}</p>
</div>
<div v-if="feedback.images?.length" class="text-sm">
<div class="font-medium">Imej</div>
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
<button v-for="image in feedback.images" :key="image.id" type="button"
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
@click="openMediaPreview(image, 'image')">
<img v-if="mediaObjectUrls[image.id]" :src="mediaObjectUrls[image.id]" :alt="image.name"
class="aspect-video w-full object-cover transition group-hover:scale-[1.02]" />
<div v-else class="flex aspect-video items-center justify-center px-2 text-center text-xs opacity-60">
{{ mediaLoadErrors[image.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
</div>
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
{{ image.name }}
</div>
</button>
</div>
</div>
<div v-if="feedback.videos?.length" class="text-sm">
<div class="font-medium">Video</div>
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<button v-for="video in feedback.videos" :key="video.id" type="button"
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
@click="openMediaPreview(video, 'video')">
<div class="relative aspect-video bg-black/80">
<video v-if="mediaObjectUrls[video.id]" :src="mediaObjectUrls[video.id]"
class="size-full object-cover opacity-80" muted preload="metadata" />
<div v-else class="flex size-full items-center justify-center px-2 text-center text-xs text-white/70">
{{ mediaLoadErrors[video.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
</div>
<div
class="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/35">
<span class="flex size-12 items-center justify-center rounded-full bg-white/90 text-foreground">
<Play class="size-5 fill-current" />
</span>
</div>
</div>
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
{{ video.name }}
</div>
</button>
</div>
</div>
</Box>
<div class="space-y-6">
<Box class="space-y-3 p-6 text-sm">
<div>
<div class="opacity-60">Penghantar</div>
<div class="mt-0.5 font-medium">{{ feedback.user?.name ?? 'Tetamu' }}</div>
<div v-if="feedback.user?.email" class="opacity-70">{{ feedback.user.email }}</div>
</div>
<div>
<div class="opacity-60">Dihantar</div>
<div class="mt-0.5">{{ formatDate(feedback.created_at) }}</div>
</div>
<div v-if="feedback.resolved_at">
<div class="opacity-60">Diselesaikan</div>
<div class="mt-0.5">{{ formatDate(feedback.resolved_at) }}</div>
</div>
<div v-if="feedback.assigned_user">
<div class="opacity-60">Ditugaskan kepada</div>
<div class="mt-0.5 font-medium">{{ feedback.assigned_user.name }}</div>
</div>
</Box>
<Box v-if="canUpdate" class="space-y-4 p-6">
<h3 class="font-medium">Tindakan Admin</h3>
<Field>
<FieldLabel>Status</FieldLabel>
<SelectRoot :key="statusInitial[0]" :collection="statusCollection" :default-value="statusInitial"
@value-change="setStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
<Field>
<FieldLabel>Nota admin</FieldLabel>
<Textarea v-model="adminForm.admin_notes" rows="4" />
</Field>
<Button variant="primary" :disabled="saving" @click="handleSave">
{{ saving ? 'Menyimpan...' : 'Simpan' }}
</Button>
</Box>
</div>
</div>
</template>
</div>
<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?.name ?? 'Pratonton lampiran'">
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
@click="closeMediaPreview" />
<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">
{{ previewKind === 'video' ? 'Main Video' : 'Pratonton Imej' }}
</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...
</div>
<div v-else-if="previewUrl && previewKind === 'image'" class="flex justify-center">
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton imej'"
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
</div>
<div v-else-if="previewUrl && previewKind === 'video'" class="flex justify-center">
<video :src="previewUrl" class="block max-h-[calc(90vh-12rem)] w-full max-w-full rounded-lg bg-black"
controls autoplay />
</div>
</div>
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
<Button type="button" look="outline" @click="closeMediaPreview">
Tutup
</Button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,289 @@
<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 { CircleAlert, Eye } from '@lucide/vue'
import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
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 DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { usePermissions } from '@/composables/usePermissions'
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
import { useFeedbackList } from '../composables/useFeedbackList'
import {
FEEDBACK_PRIORITY_OPTIONS,
FEEDBACK_STATUS_OPTIONS,
FEEDBACK_TYPE_OPTIONS,
feedbackPriorityLabel,
feedbackStatusLabel,
feedbackTypeLabel,
type FeedbackListItem,
type FeedbackPriority,
type FeedbackStatus,
type FeedbackType,
} from '../types/feedback.types'
type SelectOption = { label: string; value: string }
const TYPE_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Jenis', value: '' },
...FEEDBACK_TYPE_OPTIONS,
]
const STATUS_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Status', value: '' },
...FEEDBACK_STATUS_OPTIONS,
]
const PRIORITY_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Keutamaan', value: '' },
...FEEDBACK_PRIORITY_OPTIONS,
]
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
itemToValue: (item) => item.label,
})
}
function labelToValue(options: SelectOption[], label: string | undefined): string {
if (!label) return ''
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]?.label ?? '']
}
function statusVariant(status: FeedbackStatus): BadgeVariants['variant'] {
switch (status) {
case 'resolved':
case 'closed':
return 'success'
case 'in_progress':
return 'primary'
case 'rejected':
return 'danger'
case 'open':
return 'warning'
default:
return 'secondary'
}
}
function priorityVariant(priority: FeedbackPriority): BadgeVariants['variant'] {
switch (priority) {
case 'critical':
case 'high':
return 'danger'
case 'medium':
return 'warning'
default:
return 'secondary'
}
}
const headers: TableHeader[] = [
{ title: 'Tajuk', key: 'title', sortable: true },
{ title: 'Jenis', key: 'type', sortable: true },
{ title: 'Keutamaan', key: 'priority', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Penghantar', key: 'user' },
{ title: 'Dihantar', key: 'created_at', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
const typeCollection = createSelectCollection(TYPE_FILTER_OPTIONS)
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
const priorityCollection = createSelectCollection(PRIORITY_FILTER_OPTIONS)
const router = useRouter()
const { hasPermission } = usePermissions()
const {
items,
loading,
error,
search,
typeFilter,
statusFilter,
priorityFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
} = useFeedbackList()
const canView = computed(() => hasPermission('lihat maklum balas'))
const typeInitial = computed(() => valueToLabel(TYPE_FILTER_OPTIONS, typeFilter.value))
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
const priorityInitial = computed(() => valueToLabel(PRIORITY_FILTER_OPTIONS, priorityFilter.value))
function setTypeFilter(details: { value: string[] }) {
typeFilter.value = labelToValue(TYPE_FILTER_OPTIONS, details.value[0]) as FeedbackType | ''
}
function setStatusFilter(details: { value: string[] }) {
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as FeedbackStatus | ''
}
function setPriorityFilter(details: { value: string[] }) {
priorityFilter.value = labelToValue(PRIORITY_FILTER_OPTIONS, details.value[0]) as FeedbackPriority | ''
}
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
function goToDetail(id: string) {
router.push({ name: 'view-feedback', params: { id } })
}
function goToSubmit() {
router.push({ name: 'feedback-submit' })
}
</script>
<template>
<div>
<div class="flex flex-col items-center sm:flex-row">
<div class="mr-auto">
<h2 class="text-lg font-medium">Senarai Maklum Balas</h2>
<p class="mt-1 text-sm opacity-70">Urus laporan ralat, cadangan dan isu pengguna.</p>
</div>
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
<Button look="outline" variant="primary" class="shadow-sm" @click="goToSubmit">
Hantar Maklum Balas
</Button>
</div>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<CircleAlert />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<div class="mt-5">
<DataTable :headers="headers" :items="items" :loading="loading" :pagination="pagination" :current-sort="sortBy"
show-pagination exportable export-file-name="maklum-balas" 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">
<Input v-model="search" class="w-full max-w-md" type="search" placeholder="Cari tajuk atau penerangan..."
aria-label="Cari maklum balas" />
<SelectRoot class="w-full sm:w-52" :collection="typeCollection" :default-value="typeInitial"
@value-change="setTypeFilter">
<SelectControl>
<SelectTrigger aria-label="Tapis jenis">
<SelectValueText placeholder="Semua Jenis" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis</SelectItemGroupLabel>
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<SelectRoot class="w-full sm:w-52" :collection="statusCollection" :default-value="statusInitial"
@value-change="setStatusFilter">
<SelectControl>
<SelectTrigger aria-label="Tapis status">
<SelectValueText placeholder="Semua 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>
<SelectRoot class="w-full sm:w-52" :collection="priorityCollection" :default-value="priorityInitial"
@value-change="setPriorityFilter">
<SelectControl>
<SelectTrigger aria-label="Tapis keutamaan">
<SelectValueText placeholder="Semua Keutamaan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Keutamaan</SelectItemGroupLabel>
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</div>
</template>
<template #item.title="{ item }">
<div class="font-medium">{{ (item as FeedbackListItem).title }}</div>
<div class="mt-0.5 line-clamp-1 text-xs opacity-60">
{{ (item as FeedbackListItem).description }}
</div>
</template>
<template #item.type="{ item }">
{{ feedbackTypeLabel((item as FeedbackListItem).type) }}
</template>
<template #item.priority="{ item }">
<Badge look="outline" :variant="priorityVariant((item as FeedbackListItem).priority)">
{{ feedbackPriorityLabel((item as FeedbackListItem).priority) }}
</Badge>
</template>
<template #item.status="{ item }">
<Badge look="outline" :variant="statusVariant((item as FeedbackListItem).status)">
{{ feedbackStatusLabel((item as FeedbackListItem).status) }}
</Badge>
</template>
<template #item.user="{ item }">
{{ (item as FeedbackListItem).user?.name ?? 'Tetamu' }}
</template>
<template #item.created_at="{ item }">
{{ formatDate((item as FeedbackListItem).created_at) }}
</template>
<template #item.actions="{ item }">
<Button v-if="canView" type="button" variant="ghost" size="sm" class="bg-green-600 text-white"
title="Lihat butiran" @click="goToDetail((item as FeedbackListItem).id)">
<Eye class="size-4" aria-hidden="true" />
</Button>
</template>
</DataTable>
</div>
</div>
</template>
@@ -0,0 +1,347 @@
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { RouterLink } from 'vue-router'
import * as select from '@zag-js/select'
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
import {
AlertRoot,
AlertTitle,
AlertDescription,
} from '@/components/ui/alert'
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 { Textarea } from '@/components/ui/textarea'
import { Lucide } from '@/components/ui/lucide'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { useAuthStore } from '@/stores/auth'
import { usePermissions } from '@/composables/usePermissions'
import { submitFeedback } from '../services/feedback.service'
import {
FEEDBACK_PRIORITY_OPTIONS,
FEEDBACK_TYPE_OPTIONS,
createEmptyFeedbackForm,
type FeedbackFormState,
type FeedbackPriority,
type FeedbackType,
} from '../types/feedback.types'
import illustrationUrl from '@/assets/images/logo.svg'
type SelectOption = { label: string; value: string }
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 MAX_IMAGE_MB = 10
const MAX_VIDEO_MB = 50
const authStore = useAuthStore()
const { hasPermission } = usePermissions()
const form = reactive<FeedbackFormState>(createEmptyFeedbackForm())
const fieldErrors = reactive<Record<string, string>>({})
const loading = ref(false)
const submitted = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const typeCollection = createSelectCollection(FEEDBACK_TYPE_OPTIONS)
const priorityCollection = createSelectCollection(FEEDBACK_PRIORITY_OPTIONS)
const typeInitial = computed(() => valueToLabel(FEEDBACK_TYPE_OPTIONS, form.type))
const priorityInitial = computed(() => valueToLabel(FEEDBACK_PRIORITY_OPTIONS, form.priority))
const isAuthenticated = computed(() => authStore.isAuthenticated)
const canViewList = computed(() => hasPermission('lihat maklum balas'))
function setTypeValue(details: { value: string[] }) {
form.type = labelToValue(FEEDBACK_TYPE_OPTIONS, details.value[0]) as FeedbackType
}
function setPriorityValue(details: { value: string[] }) {
form.priority = labelToValue(FEEDBACK_PRIORITY_OPTIONS, details.value[0]) as FeedbackPriority
}
function clearFieldError(key: string) {
delete fieldErrors[key]
}
function onImagesChange(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
clearFieldError('images')
for (const file of files) {
if (file.size > MAX_IMAGE_MB * 1024 * 1024) {
fieldErrors.images = `Setiap imej mesti bawah ${MAX_IMAGE_MB}MB.`
return
}
}
form.images.push(...files)
}
function onVideosChange(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
clearFieldError('videos')
for (const file of files) {
if (file.size > MAX_VIDEO_MB * 1024 * 1024) {
fieldErrors.videos = `Setiap video mesti bawah ${MAX_VIDEO_MB}MB.`
return
}
}
form.videos.push(...files)
}
function removeImage(index: number) {
form.images.splice(index, 1)
}
function removeVideo(index: number) {
form.videos.splice(index, 1)
}
function validate(): boolean {
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
if (!form.title.trim()) fieldErrors.title = 'Tajuk diperlukan.'
if (!form.description.trim()) fieldErrors.description = 'Penerangan diperlukan.'
return Object.keys(fieldErrors).length === 0
}
function resetForm() {
Object.assign(form, createEmptyFeedbackForm())
submitted.value = false
successMessage.value = ''
errorMessage.value = ''
}
async function handleSubmit() {
if (loading.value || !validate()) return
loading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await submitFeedback({
type: form.type,
title: form.title.trim(),
description: form.description.trim(),
priority: form.priority,
page_url: form.page_url.trim() || undefined,
steps_to_reproduce: form.steps_to_reproduce.trim() || undefined,
expected_behavior: form.expected_behavior.trim() || undefined,
actual_behavior: form.actual_behavior.trim() || undefined,
additional_notes: form.additional_notes.trim() || undefined,
images: form.images,
videos: form.videos,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
viewport_size: `${window.innerWidth}x${window.innerHeight}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
submitted.value = true
successMessage.value = response.message ?? 'Maklum balas berjaya dihantar. Terima kasih!'
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors) {
Object.assign(fieldErrors, validationErrors)
}
errorMessage.value = getApiErrorMessage(err, 'Gagal menghantar maklum balas.')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-background px-4 py-10">
<div class="mx-auto w-full max-w-3xl">
<div class="mb-8 flex items-center gap-3">
<img :src="illustrationUrl" alt="MyKOPKB" class="h-10 w-auto" />
<div>
<h1 class="text-2xl font-semibold">Maklum Balas</h1>
<p class="text-sm opacity-70">
Laporkan ralat, cadangan atau isu.
</p>
</div>
</div>
<Box v-if="submitted" class="p-8 text-center">
<CircleCheck class="mx-auto size-12 text-success" />
<h2 class="mt-4 text-xl font-medium">Terima kasih!</h2>
<p class="mt-2 opacity-70">{{ successMessage }}</p>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<Button variant="primary" @click="resetForm">Hantar lagi</Button>
<RouterLink v-if="isAuthenticated && canViewList" :to="{ name: 'list-feedback' }">
<Button look="outline">Lihat senarai</Button>
</RouterLink>
<RouterLink v-else-if="!isAuthenticated" :to="{ name: 'login' }">
<Button look="outline">Log masuk</Button>
</RouterLink>
</div>
</Box>
<Box v-else class="p-6 sm:p-8">
<AlertRoot v-if="errorMessage" class="mb-6" variant="danger">
<CircleAlert class="size-4" />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<form class="space-y-5" @submit.prevent="handleSubmit">
<div class="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel>Jenis</FieldLabel>
<SelectRoot :key="typeInitial[0]" :collection="typeCollection" :default-value="typeInitial"
@value-change="setTypeValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jenis" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
<Field>
<FieldLabel>Keutamaan</FieldLabel>
<SelectRoot :key="priorityInitial[0]" :collection="priorityCollection" :default-value="priorityInitial"
@value-change="setPriorityValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih keutamaan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<Field :invalid="!!fieldErrors.title">
<FieldLabel>Tajuk</FieldLabel>
<Input v-model="form.title" placeholder="Ringkasan isu atau cadangan" @input="clearFieldError('title')" />
<FieldError v-if="fieldErrors.title">{{ fieldErrors.title }}</FieldError>
</Field>
<Field :invalid="!!fieldErrors.description">
<FieldLabel>Penerangan</FieldLabel>
<Textarea v-model="form.description" rows="5" placeholder="Terangkan dengan terperinci..."
@input="clearFieldError('description')" />
<FieldError v-if="fieldErrors.description">{{ fieldErrors.description }}</FieldError>
</Field>
<Field>
<FieldLabel>URL halaman (pilihan)</FieldLabel>
<Input v-model="form.page_url" type="url" placeholder="https://" />
</Field>
<Field>
<FieldLabel>Langkah untuk menghasilkan semula (pilihan)</FieldLabel>
<Textarea v-model="form.steps_to_reproduce" rows="3" />
</Field>
<div class="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel>Kelakuan dijangka (pilihan)</FieldLabel>
<Textarea v-model="form.expected_behavior" rows="3" />
</Field>
<Field>
<FieldLabel>Kelakuan sebenar (pilihan)</FieldLabel>
<Textarea v-model="form.actual_behavior" rows="3" />
</Field>
</div>
<Field>
<FieldLabel>Nota tambahan (pilihan)</FieldLabel>
<Textarea v-model="form.additional_notes" rows="2" />
</Field>
<Field :invalid="!!fieldErrors.images">
<FieldLabel>Imej (pilihan)</FieldLabel>
<Input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onImagesChange" />
<p class="mt-1 text-xs opacity-60">JPEG, PNG, GIF, WebP maks {{ MAX_IMAGE_MB }}MB setiap fail</p>
<FieldError v-if="fieldErrors.images">{{ fieldErrors.images }}</FieldError>
<ul v-if="form.images.length" class="mt-2 space-y-1">
<li v-for="(file, index) in form.images" :key="`${file.name}-${index}`"
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
<span class="truncate">{{ file.name }}</span>
<button type="button" class="text-danger" @click="removeImage(index)">
<Trash class="size-4" />
</button>
</li>
</ul>
</Field>
<Field :invalid="!!fieldErrors.videos">
<FieldLabel>Video (pilihan)</FieldLabel>
<Input type="file" accept="video/mp4,video/quicktime,video/webm" multiple @change="onVideosChange" />
<p class="mt-1 text-xs opacity-60">MP4, MOV, WebM maks {{ MAX_VIDEO_MB }}MB setiap fail</p>
<FieldError v-if="fieldErrors.videos">{{ fieldErrors.videos }}</FieldError>
<ul v-if="form.videos.length" class="mt-2 space-y-1">
<li v-for="(file, index) in form.videos" :key="`${file.name}-${index}`"
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
<span class="truncate">{{ file.name }}</span>
<button type="button" class="text-danger" @click="removeVideo(index)">
<Trash class="size-4" />
</button>
</li>
</ul>
</Field>
<div class="flex flex-wrap gap-3 pt-2">
<Button type="submit" variant="primary" :disabled="loading">
<Lucide v-if="loading" icon="LoaderCircle" class="mr-2 size-4 animate-spin" />
{{ loading ? 'Menghantar...' : 'Hantar Maklum Balas' }}
</Button>
<RouterLink v-if="!isAuthenticated" :to="{ name: 'login' }">
<Button type="button" look="outline">Kembali ke log masuk</Button>
</RouterLink>
</div>
</form>
</Box>
</div>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
import type { RouteRecordRaw } from 'vue-router'
export const feedbackPublicRoutes: RouteRecordRaw[] = [
{
path: '/feedback/submit',
name: 'feedback-submit',
component: () => import('./pages/FeedbackSubmit.vue'),
meta: { public: true, module: 'feedback', title: 'Maklum Balas' },
},
]
export const feedbackLayoutRoutes: RouteRecordRaw[] = [
{
path: 'feedback',
name: 'list-feedback',
component: () => import('./pages/FeedbackList.vue'),
meta: {
title: 'Senarai Maklum Balas',
module: 'feedback',
permission: 'lihat maklum balas',
},
},
{
path: 'feedback/:id',
name: 'view-feedback',
component: () => import('./pages/FeedbackDetail.vue'),
meta: {
title: 'Butiran Maklum Balas',
module: 'feedback',
permission: 'lihat maklum balas',
},
},
]
@@ -0,0 +1,170 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
import type {
Feedback,
FeedbackApiResponse,
FeedbackListItem,
FeedbackStatistics,
ListFeedbackParams,
SubmitFeedbackPayload,
UpdateFeedbackPayload,
} from '../types/feedback.types'
function appendIfPresent(formData: FormData, key: string, value: string | undefined | null) {
if (value !== null && value !== undefined && value !== '') {
formData.append(key, value)
}
}
export function buildFeedbackFormData(payload: SubmitFeedbackPayload): FormData {
const formData = new FormData()
formData.append('type', payload.type)
formData.append('title', payload.title)
formData.append('description', payload.description)
formData.append('priority', payload.priority)
appendIfPresent(formData, 'page_url', payload.page_url)
appendIfPresent(formData, 'steps_to_reproduce', payload.steps_to_reproduce)
appendIfPresent(formData, 'expected_behavior', payload.expected_behavior)
appendIfPresent(formData, 'actual_behavior', payload.actual_behavior)
appendIfPresent(formData, 'additional_notes', payload.additional_notes)
appendIfPresent(formData, 'screen_resolution', payload.screen_resolution)
appendIfPresent(formData, 'viewport_size', payload.viewport_size)
appendIfPresent(formData, 'timezone', payload.timezone)
payload.images?.forEach((file, index) => {
formData.append(`images[${index}]`, file)
})
payload.videos?.forEach((file, index) => {
formData.append(`videos[${index}]`, file)
})
return formData
}
/** Public + authenticated: POST /v1/feedback (auth cookie attaches user when present). */
export async function submitFeedback(payload: SubmitFeedbackPayload): Promise<FeedbackApiResponse> {
const formData = buildFeedbackFormData(payload)
const { data } = await api.post<FeedbackApiResponse>('/v1/feedback', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
if (!data.success) {
throw new Error(data.message ?? 'Gagal menghantar maklum balas.')
}
return data
}
export async function listFeedback(
params: ListFeedbackParams = {},
): Promise<PaginatedApiResponse<FeedbackListItem>> {
const { data } = await api.get<PaginatedApiResponse<FeedbackListItem>>('/v1/feedback', {
params,
})
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan senarai maklum balas.')
}
return data
}
export async function getFeedback(id: string): Promise<Feedback> {
const { data } = await api.get<FeedbackApiResponse>(`/v1/feedback/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan maklum balas.')
}
return data.data
}
export async function updateFeedback(
id: string,
payload: UpdateFeedbackPayload,
): Promise<FeedbackApiResponse> {
const { data } = await api.patch<FeedbackApiResponse>(`/v1/feedback/${id}`, payload)
if (!data.success) {
throw new Error(data.message ?? 'Gagal mengemas kini maklum balas.')
}
return data
}
export async function deleteFeedback(id: string): Promise<void> {
const { data } = await api.delete<{ success: boolean; message?: string }>(`/v1/feedback/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memadam maklum balas.')
}
}
export async function getMyFeedback(
params: ListFeedbackParams = {},
): Promise<PaginatedApiResponse<FeedbackListItem>> {
const { data } = await api.get<{
success: boolean
data: FeedbackListItem[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
message?: string
}>('/v1/my-feedback', { params })
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan maklum balas anda.')
}
return {
success: true,
data: data.data,
pagination: {
current_page: data.meta.current_page,
per_page: data.meta.per_page,
total: data.meta.total,
last_page: data.meta.last_page,
from: null,
to: null,
has_more_pages: data.meta.current_page < data.meta.last_page,
},
}
}
export async function getFeedbackStatistics(): Promise<FeedbackStatistics> {
const { data } = await api.get<{ success: boolean; data: FeedbackStatistics; message?: string }>(
'/v1/feedback-statistics',
)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan statistik maklum balas.')
}
return data.data
}
export async function fetchFeedbackDocument(
feedbackId: string,
documentId: string,
mimeType?: string | null,
): Promise<Blob> {
const response = await api.get(`/v1/feedback/${feedbackId}/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 })
}
@@ -0,0 +1,180 @@
export type FeedbackType =
| 'bug'
| 'feature_request'
| 'general_feedback'
| 'ui_issue'
| 'performance_issue'
export type FeedbackPriority = 'low' | 'medium' | 'high' | 'critical'
export type FeedbackStatus = 'open' | 'in_progress' | 'resolved' | 'closed' | 'rejected'
export type FeedbackDocument = {
id: string
name: string
mime_type: string | null
file_size: number | null
type: 'image' | 'video' | string
url?: string | null
}
export type FeedbackUserSummary = {
id: string
name: string
email: string
army_number?: string | null
}
export type FeedbackAssignedUser = {
id: string
name: string
email: string
}
export type Feedback = {
id: string
type: FeedbackType
title: string
description: string
priority: FeedbackPriority
status: FeedbackStatus
page_url: string | null
browser_info: Record<string, unknown> | null
images?: FeedbackDocument[]
videos?: FeedbackDocument[]
steps_to_reproduce: string | null
expected_behavior: string | null
actual_behavior: string | null
additional_notes: string | null
admin_notes: string | null
resolved_at: string | null
user?: FeedbackUserSummary | null
assigned_user?: FeedbackAssignedUser | null
created_at: string
updated_at: string
}
export type FeedbackListItem = Feedback
export type FeedbackFormState = {
type: FeedbackType
title: string
description: string
priority: FeedbackPriority
page_url: string
steps_to_reproduce: string
expected_behavior: string
actual_behavior: string
additional_notes: string
images: File[]
videos: File[]
}
export type SubmitFeedbackPayload = {
type: FeedbackType
title: string
description: string
priority: FeedbackPriority
page_url?: string
steps_to_reproduce?: string
expected_behavior?: string
actual_behavior?: string
additional_notes?: string
images?: File[]
videos?: File[]
screen_resolution?: string
viewport_size?: string
timezone?: string
}
export type UpdateFeedbackPayload = {
type?: FeedbackType
title?: string
description?: string
priority?: FeedbackPriority
page_url?: string | null
steps_to_reproduce?: string | null
expected_behavior?: string | null
actual_behavior?: string | null
additional_notes?: string | null
status?: FeedbackStatus
assigned_to?: string | null
admin_notes?: string | null
}
export type ListFeedbackParams = {
page?: number
per_page?: number
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
type?: FeedbackType | ''
status?: FeedbackStatus | ''
priority?: FeedbackPriority | ''
}
export type FeedbackApiResponse = {
success: boolean
data: Feedback
message?: string
}
export type FeedbackStatistics = {
total: number
open: number
resolved: number
by_type: Record<string, number>
by_priority: Record<string, number>
by_status: Record<string, number>
}
export const FEEDBACK_TYPE_OPTIONS: { label: string; value: FeedbackType }[] = [
{ label: 'Ralat / Bug', value: 'bug' },
{ label: 'Permintaan Ciri Baharu', value: 'feature_request' },
{ label: 'Maklum Balas Umum', value: 'general_feedback' },
{ label: 'Isu Antara Muka', value: 'ui_issue' },
{ label: 'Isu Prestasi', value: 'performance_issue' },
]
export const FEEDBACK_PRIORITY_OPTIONS: { label: string; value: FeedbackPriority }[] = [
{ label: 'Rendah', value: 'low' },
{ label: 'Sederhana', value: 'medium' },
{ label: 'Tinggi', value: 'high' },
{ label: 'Kritikal', value: 'critical' },
]
export const FEEDBACK_STATUS_OPTIONS: { label: string; value: FeedbackStatus }[] = [
{ label: 'Terbuka', value: 'open' },
{ label: 'Dalam Proses', value: 'in_progress' },
{ label: 'Diselesaikan', value: 'resolved' },
{ label: 'Ditutup', value: 'closed' },
{ label: 'Ditolak', value: 'rejected' },
]
export function feedbackTypeLabel(type: FeedbackType | string): string {
return FEEDBACK_TYPE_OPTIONS.find((option) => option.value === type)?.label ?? type
}
export function feedbackPriorityLabel(priority: FeedbackPriority | string): string {
return FEEDBACK_PRIORITY_OPTIONS.find((option) => option.value === priority)?.label ?? priority
}
export function feedbackStatusLabel(status: FeedbackStatus | string): string {
return FEEDBACK_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
}
export function createEmptyFeedbackForm(): FeedbackFormState {
return {
type: 'general_feedback',
title: '',
description: '',
priority: 'medium',
page_url: typeof window !== 'undefined' ? window.location.href : '',
steps_to_reproduce: '',
expected_behavior: '',
actual_behavior: '',
additional_notes: '',
images: [],
videos: [],
}
}
@@ -21,6 +21,7 @@ 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 { HelpdeskFab } from '@/modules/feedback'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import type {
@@ -567,6 +568,7 @@ function stepLabelClass(stepId: number) {
<template>
<div class="min-h-screen bg-background">
<HelpdeskFab />
<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">
+6 -2
View File
@@ -13,6 +13,7 @@ import { activityLayoutRoutes } from '@/modules/activity'
import { dashboardLayoutRoutes } from '@/modules/dashboard'
import { externalSystemLayoutRoutes } from '@/modules/external-system'
import { activityLogLayoutRoutes } from '@/modules/activity-log'
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -30,15 +31,17 @@ const router = createRouter({
...activityLayoutRoutes,
...externalSystemLayoutRoutes,
...membershipApplicationLayoutRoutes,
...feedbackLayoutRoutes,
],
},
...authPublicRoutes,
...membershipApplicationPublicRoutes,
...feedbackPublicRoutes,
...profilePublicRoutes,
],
})
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply'])
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply', 'feedback-submit'])
router.beforeEach(async (to) => {
const authStore = useAuthStore(pinia)
@@ -69,7 +72,8 @@ router.beforeEach(async (to) => {
if (
authStore.isAuthenticated &&
PUBLIC_ROUTE_NAMES.has(routeName) &&
routeName !== 'membership-application-apply'
routeName !== 'membership-application-apply' &&
routeName !== 'feedback-submit'
) {
return resolvePostAuthRoute(authStore.user)
}
+3 -10
View File
@@ -4,6 +4,7 @@ import { Lucide, type Icon as LucideIcon } from '@/components/ui/lucide'
import { useRoute, useRouter } from 'vue-router'
import { onMounted, computed } from 'vue'
import { Icon } from '@iconify/vue'
import { HelpdeskFab } from '@/modules/feedback'
const route = useRoute()
const router = useRouter()
@@ -58,16 +59,6 @@ const sideTabs: SideTab[] = [
window.open('https://www.facebook.com/KoperasiPermodalanKelantanBerhad/', '_blank')
},
},
// {
// id: 'feedback-page',
// label: 'Helpdesk',
// iconType: 'lucide',
// icon: 'MessageCircle',
// offsetRem: 10.5,
// onClick: () => {
// window.open('https://docs.google.com/forms/d/e/1FAIpQLSd_0p-kKmsHq9z4fZ3e6X13V6O64s1-CpJw4_vR768Gg0_sA/viewform?usp=header', '_blank')
// },
// },
]
onMounted(() => {
@@ -106,6 +97,8 @@ onMounted(() => {
</span>
</button>
<HelpdeskFab :offset-rem="10.5" />
<Component />
</div>
</template>