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
}