DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogCloseTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createActivityReport, deleteActivityReport, updateActivityReport } from '../services/activity.service'
|
||||
import type { ActivityReport } from '../types/activity.types'
|
||||
|
||||
type ReportModalMode = 'create' | 'edit'
|
||||
|
||||
const props = defineProps<{
|
||||
activityId: string
|
||||
reports: ActivityReport[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreateReport = computed(() => hasPermission('tambah laporan aktiviti'))
|
||||
const canEditReport = computed(() => hasPermission('kemaskini laporan aktiviti'))
|
||||
const canDeleteReport = computed(() => hasPermission('hapus laporan aktiviti'))
|
||||
|
||||
const reportModalOpen = ref(false)
|
||||
const reportModalMode = ref<ReportModalMode>('create')
|
||||
const editingReportId = ref<string | null>(null)
|
||||
const reportText = ref('')
|
||||
const reportFieldError = ref<string | null>(null)
|
||||
const savingReport = ref(false)
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const reportToDelete = ref<ActivityReport | null>(null)
|
||||
const deletingReport = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const isEditMode = computed(() => reportModalMode.value === 'edit')
|
||||
|
||||
const reportModalTitle = computed(() =>
|
||||
isEditMode.value ? 'Kemaskini Laporan Aktiviti' : 'Tambah Laporan Aktiviti',
|
||||
)
|
||||
|
||||
const reportModalDescription = computed(() =>
|
||||
isEditMode.value
|
||||
? 'Kemaskini kandungan laporan aktiviti ini.'
|
||||
: 'Rekod laporan untuk aktiviti ini. Laporan akan dikaitkan dengan akaun anda.',
|
||||
)
|
||||
|
||||
const reportSubmitLabel = computed(() => {
|
||||
if (savingReport.value) return 'Menyimpan...'
|
||||
return isEditMode.value ? 'Simpan Perubahan' : 'Simpan Laporan'
|
||||
})
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function resetReportForm() {
|
||||
reportText.value = ''
|
||||
reportFieldError.value = null
|
||||
editingReportId.value = null
|
||||
reportModalMode.value = 'create'
|
||||
}
|
||||
|
||||
function openCreateReportModal() {
|
||||
resetReportForm()
|
||||
reportModalMode.value = 'create'
|
||||
reportModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditReportModal(report: ActivityReport) {
|
||||
reportFieldError.value = null
|
||||
reportModalMode.value = 'edit'
|
||||
editingReportId.value = report.id
|
||||
reportText.value = report.report_text
|
||||
reportModalOpen.value = true
|
||||
}
|
||||
|
||||
function handleReportModalOpenChange(details: { open: boolean }) {
|
||||
if (savingReport.value && !details.open) {
|
||||
reportModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
reportModalOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
resetReportForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReport() {
|
||||
if (savingReport.value) return
|
||||
|
||||
const trimmedText = reportText.value.trim()
|
||||
if (!trimmedText) {
|
||||
reportFieldError.value = 'Laporan diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
savingReport.value = true
|
||||
reportFieldError.value = null
|
||||
|
||||
try {
|
||||
if (isEditMode.value && editingReportId.value) {
|
||||
await updateActivityReport(editingReportId.value, {
|
||||
activity_id: props.activityId,
|
||||
report_text: trimmedText,
|
||||
})
|
||||
} else {
|
||||
await createActivityReport({
|
||||
activity_id: props.activityId,
|
||||
report_text: trimmedText,
|
||||
})
|
||||
}
|
||||
|
||||
reportModalOpen.value = false
|
||||
resetReportForm()
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.report_text?.[0]) {
|
||||
reportFieldError.value = validationErrors.report_text[0]
|
||||
} else {
|
||||
reportFieldError.value = getApiErrorMessage(
|
||||
err,
|
||||
isEditMode.value
|
||||
? 'Gagal mengemaskini laporan aktiviti.'
|
||||
: 'Gagal menambah laporan aktiviti.',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
savingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(report: ActivityReport) {
|
||||
reportToDelete.value = report
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteConfirmationOpenChange(details: { open: boolean }) {
|
||||
if (deletingReport.value && !details.open) {
|
||||
deleteConfirmationOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
deleteConfirmationOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
reportToDelete.value = null
|
||||
deleteError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteReport() {
|
||||
if (!reportToDelete.value || deletingReport.value) return
|
||||
|
||||
deletingReport.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteActivityReport(reportToDelete.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
reportToDelete.value = null
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal memadam laporan aktiviti.')
|
||||
} finally {
|
||||
deletingReport.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Laporan Aktiviti</div>
|
||||
<p class="mt-1 text-sm opacity-70">{{ reports.length }} laporan</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canCreateReport"
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
@click="openCreateReportModal"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Plus" />
|
||||
Tambah Laporan
|
||||
</Button>
|
||||
<Lucide class="size-5 opacity-50" icon="FileText" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!reports.length" class="mt-5 opacity-70">Tiada laporan aktiviti.</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-4">
|
||||
<div
|
||||
v-for="report in reports"
|
||||
:key="report.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-foreground/10 pb-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ report.prepared_by_user?.name ?? 'Pengguna' }}
|
||||
</div>
|
||||
<div v-if="report.prepared_by_user?.email" class="mt-0.5 text-xs opacity-70">
|
||||
{{ report.prepared_by_user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xs opacity-70">
|
||||
{{ formatDateTime(report.created_at) }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="canEditReport"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
title="Kemaskini laporan"
|
||||
@click="openEditReportModal(report)"
|
||||
>
|
||||
<Lucide class="size-4 opacity-70" icon="Pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteReport"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
title="Padam laporan"
|
||||
@click="openDeleteConfirmation(report)"
|
||||
>
|
||||
<Lucide class="size-4 text-danger opacity-80" icon="Trash2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 whitespace-pre-line text-sm leading-relaxed">
|
||||
{{ report.report_text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<DialogRoot :open="reportModalOpen" @openChange="handleReportModalOpenChange">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogTitle>{{ reportModalTitle }}</DialogTitle>
|
||||
<DialogDescription>{{ reportModalDescription }}</DialogDescription>
|
||||
|
||||
<form class="mt-4 space-y-4" @submit.prevent="handleSubmitReport">
|
||||
<Field>
|
||||
<FieldLabel for="activity-report-text">Laporan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-report-text"
|
||||
v-model="reportText"
|
||||
rows="6"
|
||||
placeholder="Tulis laporan aktiviti..."
|
||||
:disabled="savingReport"
|
||||
required
|
||||
/>
|
||||
<FieldError v-if="reportFieldError">{{ reportFieldError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<DialogCloseTrigger look="outline" variant="secondary" :disabled="savingReport">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button type="submit" variant="primary" :disabled="savingReport">
|
||||
{{ reportSubmitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="handleDeleteConfirmationOpenChange">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Padam Laporan Aktiviti?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu memadam laporan ini?
|
||||
<br />
|
||||
Proses ini tidak boleh dibatalkan.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deletingReport">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deletingReport"
|
||||
@click="confirmDeleteReport"
|
||||
>
|
||||
{{ deletingReport ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogCloseTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createActivityType, listActivityTypes } from '../services/activity.service'
|
||||
import type { ActivityType } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
autoSelectFirst?: boolean
|
||||
}>(),
|
||||
{
|
||||
autoSelectFirst: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreateType = computed(() => hasPermission('tambah jenis aktiviti'))
|
||||
|
||||
const loading = ref(false)
|
||||
const activityTypes = ref<ActivityType[]>([])
|
||||
const selectKey = ref(0)
|
||||
const selectInitial = ref<string[]>([])
|
||||
|
||||
const typeModalOpen = ref(false)
|
||||
const typeName = ref('')
|
||||
const typeCode = ref('')
|
||||
const typeNameError = ref<string | null>(null)
|
||||
const typeCodeError = ref<string | null>(null)
|
||||
const savingType = ref(false)
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
function formatTypeLabel(type: ActivityType): string {
|
||||
return type.code ? `${type.name} (${type.code})` : type.name
|
||||
}
|
||||
|
||||
const activityTypeOptions = computed<SelectOption[]>(() =>
|
||||
activityTypes.value.map((type) => ({
|
||||
label: formatTypeLabel(type),
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const activityTypeCollection = computed(() =>
|
||||
createSelectCollection(activityTypeOptions.value),
|
||||
)
|
||||
|
||||
function syncSelectInitial() {
|
||||
if (!model.value) {
|
||||
selectInitial.value = []
|
||||
return
|
||||
}
|
||||
|
||||
selectInitial.value = valueToLabel(activityTypeOptions.value, model.value)
|
||||
selectKey.value += 1
|
||||
}
|
||||
|
||||
function setActivityTypeValue(details: { value: string[] }) {
|
||||
model.value = labelToValue(activityTypeOptions.value, details.value[0])
|
||||
}
|
||||
|
||||
function resetTypeForm() {
|
||||
typeName.value = ''
|
||||
typeCode.value = ''
|
||||
typeNameError.value = null
|
||||
typeCodeError.value = null
|
||||
}
|
||||
|
||||
function openTypeModal() {
|
||||
resetTypeForm()
|
||||
typeModalOpen.value = true
|
||||
}
|
||||
|
||||
function handleTypeModalOpenChange(details: { open: boolean }) {
|
||||
if (savingType.value && !details.open) {
|
||||
typeModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
typeModalOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
resetTypeForm()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActivityTypes() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
activityTypes.value = await listActivityTypes()
|
||||
syncSelectInitial()
|
||||
|
||||
if (props.autoSelectFirst && !model.value && activityTypes.value.length > 0) {
|
||||
model.value = activityTypes.value[0]!.id
|
||||
syncSelectInitial()
|
||||
}
|
||||
} catch {
|
||||
activityTypes.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateType() {
|
||||
if (savingType.value) return
|
||||
|
||||
const trimmedName = typeName.value.trim()
|
||||
const trimmedCode = typeCode.value.trim()
|
||||
|
||||
typeNameError.value = null
|
||||
typeCodeError.value = null
|
||||
|
||||
if (!trimmedName) {
|
||||
typeNameError.value = 'Nama jenis aktiviti diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
savingType.value = true
|
||||
|
||||
try {
|
||||
const created = await createActivityType({
|
||||
name: trimmedName,
|
||||
code: trimmedCode || null,
|
||||
})
|
||||
|
||||
activityTypes.value = [...activityTypes.value, created]
|
||||
model.value = created.id
|
||||
syncSelectInitial()
|
||||
|
||||
typeModalOpen.value = false
|
||||
resetTypeForm()
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.name?.[0]) {
|
||||
typeNameError.value = validationErrors.name[0]
|
||||
}
|
||||
if (validationErrors?.code?.[0]) {
|
||||
typeCodeError.value = validationErrors.code[0]
|
||||
}
|
||||
if (!typeNameError.value && !typeCodeError.value) {
|
||||
typeNameError.value = getApiErrorMessage(err, 'Gagal menambah jenis aktiviti.')
|
||||
}
|
||||
} finally {
|
||||
savingType.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
if (activityTypes.value.length) {
|
||||
syncSelectInitial()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivityTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Aktiviti</FieldLabel>
|
||||
<div class="flex items-start gap-2">
|
||||
<SelectRoot
|
||||
:key="selectKey"
|
||||
class="min-w-0 flex-1"
|
||||
:collection="activityTypeCollection"
|
||||
:default-value="selectInitial"
|
||||
:disabled="props.disabled || loading || !activityTypeOptions.length"
|
||||
@value-change="setActivityTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText
|
||||
:placeholder="loading ? 'Memuatkan jenis aktiviti...' : 'Pilih jenis aktiviti'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activityTypeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<Button
|
||||
v-if="canCreateType"
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
class="shrink-0"
|
||||
:disabled="props.disabled || savingType"
|
||||
title="Tambah jenis aktiviti"
|
||||
@click="openTypeModal"
|
||||
>
|
||||
<Lucide class="size-4" icon="Plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<DialogRoot :open="typeModalOpen" @openChange="handleTypeModalOpenChange">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogTitle>Tambah Jenis Aktiviti</DialogTitle>
|
||||
<DialogDescription>
|
||||
Daftar jenis aktiviti baharu untuk digunakan dalam borang ini.
|
||||
</DialogDescription>
|
||||
|
||||
<form class="mt-4 space-y-4" @submit.prevent="handleCreateType">
|
||||
<Field>
|
||||
<FieldLabel for="activity-type-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="activity-type-name"
|
||||
v-model="typeName"
|
||||
placeholder="Contoh: Mesyuarat Agung"
|
||||
:disabled="savingType"
|
||||
required
|
||||
/>
|
||||
<FieldError v-if="typeNameError">{{ typeNameError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-type-code">Kod</FieldLabel>
|
||||
<Input
|
||||
id="activity-type-code"
|
||||
v-model="typeCode"
|
||||
placeholder="Contoh: MA (pilihan)"
|
||||
:disabled="savingType"
|
||||
/>
|
||||
<FieldError v-if="typeCodeError">{{ typeCodeError }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<DialogCloseTrigger look="outline" variant="secondary" :disabled="savingType">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button type="submit" variant="primary" :disabled="savingType">
|
||||
{{ savingType ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getActivity } from '../services/activity.service'
|
||||
import type { ActivityDetail } from '../types/activity.types'
|
||||
|
||||
export function useActivityDetail() {
|
||||
const route = useRoute()
|
||||
const activity = ref<ActivityDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const activityId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
async function fetchActivity() {
|
||||
if (!activityId.value) {
|
||||
error.value = 'ID aktiviti tidak sah.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
activity.value = await getActivity(activityId.value)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran aktiviti.')
|
||||
activity.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activityId, () => {
|
||||
fetchActivity()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivity()
|
||||
})
|
||||
|
||||
return {
|
||||
activity,
|
||||
activityId,
|
||||
loading,
|
||||
error,
|
||||
fetchActivity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listActivities } from '../services/activity.service'
|
||||
import type { ActivityListItem } from '../types/activity.types'
|
||||
|
||||
export function useActivityList() {
|
||||
const activities = ref<ActivityListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
const sortBy = ref('start_datetime')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchActivities(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listActivities({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
activities.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai aktiviti.')
|
||||
activities.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchActivities(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([sortBy, sortOrder], () => {
|
||||
fetchActivities(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchActivities(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchActivities(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivities(1)
|
||||
})
|
||||
|
||||
return {
|
||||
activities,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination,
|
||||
fetchActivities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activityLayoutRoutes } from './routes'
|
||||
export { activityMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const activityMenu: Menu[] = [
|
||||
{
|
||||
icon: 'CalendarDays',
|
||||
route_name: 'list-activities',
|
||||
title: 'Aktiviti',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,405 @@
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityTypeSelectField from '../components/ActivityTypeSelectField.vue'
|
||||
import { createActivity } from '../services/activity.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type StagedGalleryFile = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
type StagedDocumentFile = {
|
||||
id: string
|
||||
file: File
|
||||
}
|
||||
|
||||
const ACTIVE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Aktif', value: '1' },
|
||||
{ label: 'Tidak Aktif', value: '0' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const galleryFiles = ref<StagedGalleryFile[]>([])
|
||||
const documentFiles = ref<StagedDocumentFile[]>([])
|
||||
|
||||
const form = reactive({
|
||||
activity_type_id: '',
|
||||
title: '',
|
||||
reference_number: '',
|
||||
description: '',
|
||||
start_datetime: '',
|
||||
end_datetime: '',
|
||||
organizer: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const activeStatusCollection = createSelectCollection(ACTIVE_STATUS_OPTIONS)
|
||||
|
||||
const activeStatusInitial = ref<string[]>(['Aktif'])
|
||||
|
||||
function setActiveStatusValue(details: { value: string[] }) {
|
||||
form.is_active = labelToValue(ACTIVE_STATUS_OPTIONS, details.value[0]) === '1'
|
||||
}
|
||||
|
||||
function onGalleryFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
galleryFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
documentFiles.value.push(
|
||||
...files.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function removeGalleryFile(id: string) {
|
||||
const item = galleryFiles.value.find((file) => file.id === id)
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
}
|
||||
galleryFiles.value = galleryFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function removeDocumentFile(id: string) {
|
||||
documentFiles.value = documentFiles.value.filter((file) => file.id !== id)
|
||||
}
|
||||
|
||||
function revokeGalleryPreviewUrls() {
|
||||
galleryFiles.value.forEach((file) => URL.revokeObjectURL(file.previewUrl))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.activity_type_id) {
|
||||
error.value = 'Sila pilih jenis aktiviti.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const activity = await createActivity(
|
||||
{
|
||||
activity_type_id: form.activity_type_id,
|
||||
title: form.title.trim(),
|
||||
reference_number: form.reference_number.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
start_datetime: form.start_datetime || null,
|
||||
end_datetime: form.end_datetime || null,
|
||||
organizer: form.organizer.trim() || null,
|
||||
location: form.location.trim() || null,
|
||||
is_active: form.is_active,
|
||||
},
|
||||
{
|
||||
gallery: galleryFiles.value.map((item) => item.file),
|
||||
documents: documentFiles.value.map((item) => item.file),
|
||||
},
|
||||
)
|
||||
|
||||
successMessage.value = 'Aktiviti berjaya didaftarkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-activity', params: { id: activity.id } })
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mendaftar aktiviti.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-activities' })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeGalleryPreviewUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Daftar Aktiviti</h2>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-title">Tajuk</FieldLabel>
|
||||
<Input id="activity-title" v-model="form.title" required :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<ActivityTypeSelectField
|
||||
v-model="form.activity_type_id"
|
||||
:disabled="saving"
|
||||
auto-select-first
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="activeStatusCollection"
|
||||
:default-value="activeStatusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setActiveStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activeStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-reference">No. Rujukan</FieldLabel>
|
||||
<Input
|
||||
id="activity-reference"
|
||||
v-model="form.reference_number"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-organizer">Penganjur</FieldLabel>
|
||||
<Input id="activity-organizer" v-model="form.organizer" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-location">Lokasi</FieldLabel>
|
||||
<Input id="activity-location" v-model="form.location" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-start">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="activity-start"
|
||||
v-model="form.start_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-end">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="activity-end"
|
||||
v-model="form.end_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-description">Penerangan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah imej beberapa kali sebelum mendaftar. Imej dimuat naik semasa pendaftaran.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="galleryFiles.length"
|
||||
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="image in galleryFiles"
|
||||
:key="image.id"
|
||||
class="relative overflow-hidden rounded-lg border border-foreground/10"
|
||||
>
|
||||
<img
|
||||
:src="image.previewUrl"
|
||||
:alt="image.file.name"
|
||||
class="aspect-square w-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2"
|
||||
:disabled="saving"
|
||||
@click="removeGalleryFile(image.id)"
|
||||
>
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-gallery-upload">Tambah Imej Galeri</FieldLabel>
|
||||
<input
|
||||
id="activity-gallery-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="saving"
|
||||
@change="onGalleryFilesChange"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Pilihan. Tambah dokumen beberapa kali sebelum mendaftar. Dokumen dimuat naik semasa pendaftaran.
|
||||
</p>
|
||||
|
||||
<div v-if="documentFiles.length" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="document in documentFiles"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.file.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.file.type || 'Dokumen' }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="removeDocumentFile(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-documents-upload">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="activity-documents-upload"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="goBack">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving || !form.activity_type_id">
|
||||
{{ saving ? 'Menyimpan...' : 'Daftar Aktiviti' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,547 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, Download, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogCloseTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselIndicatorGroup,
|
||||
CarouselIndicator,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityReportsSection from '../components/ActivityReportsSection.vue'
|
||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||
import {
|
||||
deleteActivity,
|
||||
downloadActivityDocument,
|
||||
fetchActivityDocument,
|
||||
} from '../services/activity.service'
|
||||
import type { ActivityDocument } from '../types/activity.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
const { activity, loading, error, fetchActivity } = useActivityDetail()
|
||||
|
||||
const canEdit = computed(() => hasPermission('kemaskini aktiviti'))
|
||||
const canDelete = computed(() => hasPermission('hapus aktiviti'))
|
||||
|
||||
const galleryImages = computed(() => activity.value?.gallery_images ?? [])
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const deletingActivity = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const documentPreviewOpen = ref(false)
|
||||
const previewDocument = ref<ActivityDocument | null>(null)
|
||||
const documentPreviewUrl = ref<string | null>(null)
|
||||
const documentPreviewLoading = ref(false)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
|
||||
const isDocumentPreviewImage = computed(() =>
|
||||
previewDocument.value?.mime_type?.startsWith('image/') ?? false,
|
||||
)
|
||||
|
||||
const isDocumentPreviewPdf = computed(() =>
|
||||
previewDocument.value?.mime_type === 'application/pdf',
|
||||
)
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function documentLabel(document: ActivityDocument): string {
|
||||
if (document.type && document.type !== 'general') {
|
||||
return document.type
|
||||
}
|
||||
return document.name
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null) {
|
||||
if (url) {
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function revokeDocumentPreviewUrl() {
|
||||
revokeUrl(documentPreviewUrl.value)
|
||||
documentPreviewUrl.value = null
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: ActivityDocument) {
|
||||
if (!activity.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadActivityDocument(
|
||||
activity.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: ActivityDocument) {
|
||||
if (!activity.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
documentPreviewOpen.value = true
|
||||
documentPreviewLoading.value = true
|
||||
revokeDocumentPreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchActivityDocument(
|
||||
activity.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
documentPreviewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
closeDocumentPreview()
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan dokumen.')
|
||||
} finally {
|
||||
documentPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewOpen.value = false
|
||||
previewDocument.value = null
|
||||
revokeDocumentPreviewUrl()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-activities' })
|
||||
}
|
||||
|
||||
function goToEdit() {
|
||||
if (!activity.value) return
|
||||
router.push({ name: 'edit-activity', params: { id: activity.value.id } })
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function openDeleteConfirmation() {
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteConfirmationOpenChange(details: { open: boolean }) {
|
||||
if (deletingActivity.value && !details.open) {
|
||||
deleteConfirmationOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
deleteConfirmationOpen.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
deleteError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteActivity() {
|
||||
if (!activity.value || deletingActivity.value) return
|
||||
|
||||
deletingActivity.value = true
|
||||
deleteError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await deleteActivity(activity.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
router.push({ name: 'list-activities' })
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal memadam aktiviti.')
|
||||
} finally {
|
||||
deletingActivity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeDocumentPreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Aktiviti</h2>
|
||||
<p v-if="activity" class="mt-1 text-sm opacity-70">
|
||||
{{ activity.reference_number ?? activity.id }} · {{ activity.title }}
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
<Button v-if="canEdit" type="button" @click="goToEdit">
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
@click="openDeleteConfirmation"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Trash2" />
|
||||
Hapus
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="clearError" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan butiran aktiviti...</div>
|
||||
|
||||
<template v-else-if="activity">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm opacity-70">Tajuk Aktiviti</div>
|
||||
<div class="text-xl font-semibold">{{ activity.title }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge look="outline" :variant="activity.is_active ? 'success' : 'outline'">
|
||||
{{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</Badge>
|
||||
<Badge v-if="activity.activity_type" look="outline" variant="pending">
|
||||
{{ activity.activity_type.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">No. Rujukan:</span>
|
||||
{{ activity.reference_number ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Penganjur:</span>
|
||||
{{ activity.organizer ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Lokasi:</span>
|
||||
{{ activity.location ?? '-' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Mula:</span>
|
||||
{{ formatDateTime(activity.start_datetime) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Tamat:</span>
|
||||
{{ formatDateTime(activity.end_datetime) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Dicipta:</span>
|
||||
{{ formatDateTime(activity.created_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Dikemaskini:</span>
|
||||
{{ formatDateTime(activity.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activity.description" class="mt-5 border-t border-foreground/10 pt-5">
|
||||
<div class="text-sm font-medium opacity-70">Penerangan</div>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-relaxed">{{ activity.description }}</p>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="!galleryImages.length" class="p-5 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">0 imej</p>
|
||||
</div>
|
||||
<Lucide class="size-5 opacity-50" icon="Images" />
|
||||
</div>
|
||||
<div class="mt-5 opacity-70">Tiada imej galeri dimuat naik.</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else class="overflow-hidden p-0">
|
||||
<CarouselRoot
|
||||
:default-page="0"
|
||||
:slide-count="galleryImages.length"
|
||||
class="w-full border-0"
|
||||
>
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-4">
|
||||
<div class="mr-auto min-w-0">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">{{ galleryImages.length }} imej</p>
|
||||
</div>
|
||||
<Lucide class="mr-3 size-5 opacity-50" icon="Images" />
|
||||
<template v-if="galleryImages.length > 1">
|
||||
<CarouselPrevTrigger as-child>
|
||||
<Button variant="ghost" class="mr-2 border border-foreground/15 shadow-none" type="button">
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</Button>
|
||||
</CarouselPrevTrigger>
|
||||
<CarouselNextTrigger as-child>
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none" type="button">
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</Button>
|
||||
</CarouselNextTrigger>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-2">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="image.id"
|
||||
:index="index"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div
|
||||
class="flex w-full items-center justify-center overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5"
|
||||
style="min-height: 16rem; max-height: 28rem"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
class="block max-h-112 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 w-full text-center">
|
||||
<div class="truncate text-sm font-medium">{{ image.name }}</div>
|
||||
<div v-if="image.description" class="mt-1 text-xs opacity-70">
|
||||
{{ image.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="galleryImages.length > 1"
|
||||
class="flex justify-center border-t border-foreground/10 px-5 py-4"
|
||||
>
|
||||
<CarouselIndicatorGroup>
|
||||
<CarouselIndicator
|
||||
v-for="(image, index) in galleryImages"
|
||||
:key="image.id"
|
||||
:index="index"
|
||||
/>
|
||||
</CarouselIndicatorGroup>
|
||||
</div>
|
||||
</CarouselRoot>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ activity.documents.length }} dokumen · muat turun atau lihat
|
||||
</p>
|
||||
</div>
|
||||
<Lucide class="size-5 opacity-50" icon="Paperclip" />
|
||||
</div>
|
||||
|
||||
<div v-if="!activity.documents.length" class="mt-5 opacity-70">
|
||||
Tiada dokumen lampiran.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="document in activity.documents"
|
||||
:key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Lucide
|
||||
class="size-4 shrink-0 opacity-60"
|
||||
:icon="document.mime_type?.startsWith('image/') ? 'Image' : 'FileText'"
|
||||
/>
|
||||
<div class="truncate font-medium">{{ documentLabel(document) }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ document.name }} · {{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
<div v-if="document.description" class="mt-1 text-sm opacity-70">
|
||||
{{ document.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="documentPreviewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)"
|
||||
>
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{
|
||||
documentPreviewLoading && previewDocument?.id === document.id
|
||||
? 'Memuatkan...'
|
||||
: 'Lihat'
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{
|
||||
downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<ActivityReportsSection
|
||||
v-if="activity"
|
||||
:activity-id="activity.id"
|
||||
:reports="activity.reports ?? []"
|
||||
@changed="fetchActivity"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="documentPreviewOpen"
|
||||
class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="previewDocument ? documentLabel(previewDocument) : 'Pratonton dokumen'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/80"
|
||||
aria-label="Tutup pratonton dokumen"
|
||||
@click="closeDocumentPreview"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument ? documentLabel(previewDocument) : 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="documentPreviewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="documentPreviewUrl && isDocumentPreviewImage" class="flex justify-center">
|
||||
<img
|
||||
:src="documentPreviewUrl"
|
||||
:alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-else-if="documentPreviewUrl && isDocumentPreviewPdf"
|
||||
:src="documentPreviewUrl"
|
||||
class="block w-full rounded-lg border border-foreground/10"
|
||||
style="height: min(70vh, 720px)"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
/>
|
||||
|
||||
<div v-else-if="documentPreviewUrl" class="py-12 text-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button
|
||||
v-if="previewDocument"
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id"
|
||||
@click="handleDownloadDocument(previewDocument)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
<Button type="button" look="outline" @click="closeDocumentPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="handleDeleteConfirmationOpenChange">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Padam Aktiviti?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu memadam
|
||||
<span v-if="activity" class="font-medium">{{ activity.title }}</span>?
|
||||
<br />
|
||||
Semua dokumen, galeri, dan laporan berkaitan turut akan dipadam.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deletingActivity">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deletingActivity"
|
||||
@click="confirmDeleteActivity"
|
||||
>
|
||||
{{ deletingActivity ? 'Memadam...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,504 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { input as inputStyles } from '@/components/ui/styles/input.styles'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@mykopkb/core/utils/cn'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import ActivityReportsSection from '../components/ActivityReportsSection.vue'
|
||||
import ActivityTypeSelectField from '../components/ActivityTypeSelectField.vue'
|
||||
import {
|
||||
deleteActivityDocument,
|
||||
deleteActivityGalleryImage,
|
||||
getActivity,
|
||||
updateActivity,
|
||||
uploadActivityDocument,
|
||||
uploadActivityGalleryImage,
|
||||
} from '../services/activity.service'
|
||||
import type { ActivityDetail } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const ACTIVE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Aktif', value: '1' },
|
||||
{ label: 'Tidak Aktif', value: '0' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(value: string | null): string {
|
||||
if (!value) return ''
|
||||
return dayjs(value).format('YYYY-MM-DDTHH:mm')
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const activityId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploadingGallery = ref(false)
|
||||
const uploadingDocuments = ref(false)
|
||||
const deletingAssetId = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const activity = ref<ActivityDetail | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
activity_type_id: '',
|
||||
title: '',
|
||||
reference_number: '',
|
||||
description: '',
|
||||
start_datetime: '',
|
||||
end_datetime: '',
|
||||
organizer: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const activeStatusCollection = createSelectCollection(ACTIVE_STATUS_OPTIONS)
|
||||
|
||||
const activeStatusInitial = ref<string[]>([])
|
||||
|
||||
function setActiveStatusValue(details: { value: string[] }) {
|
||||
form.is_active = labelToValue(ACTIVE_STATUS_OPTIONS, details.value[0]) === '1'
|
||||
}
|
||||
|
||||
function syncFormFromActivity(data: ActivityDetail) {
|
||||
form.activity_type_id = data.activity_type_id
|
||||
form.title = data.title
|
||||
form.reference_number = data.reference_number ?? ''
|
||||
form.description = data.description ?? ''
|
||||
form.start_datetime = toDatetimeLocalValue(data.start_datetime)
|
||||
form.end_datetime = toDatetimeLocalValue(data.end_datetime)
|
||||
form.organizer = data.organizer ?? ''
|
||||
form.location = data.location ?? ''
|
||||
form.is_active = data.is_active
|
||||
|
||||
activeStatusInitial.value = valueToLabel(
|
||||
ACTIVE_STATUS_OPTIONS,
|
||||
data.is_active ? '1' : '0',
|
||||
)
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
const data = await getActivity(activityId.value)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await loadActivity()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat aktiviti.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showTransientSuccess(message: string) {
|
||||
successMessage.value = message
|
||||
setTimeout(() => {
|
||||
if (successMessage.value === message) {
|
||||
successMessage.value = null
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
async function onGalleryFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length || uploadingGallery.value) return
|
||||
|
||||
uploadingGallery.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const data = await uploadActivityGalleryImage(activityId.value, file)
|
||||
activity.value = data
|
||||
}
|
||||
|
||||
showTransientSuccess(
|
||||
files.length === 1
|
||||
? 'Imej galeri berjaya dimuat naik.'
|
||||
: `${files.length} imej galeri berjaya dimuat naik.`,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik imej galeri.')
|
||||
} finally {
|
||||
uploadingGallery.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDocumentFilesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length || uploadingDocuments.value) return
|
||||
|
||||
uploadingDocuments.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const data = await uploadActivityDocument(activityId.value, file)
|
||||
activity.value = data
|
||||
}
|
||||
|
||||
showTransientSuccess(
|
||||
files.length === 1
|
||||
? 'Dokumen berjaya dimuat naik.'
|
||||
: `${files.length} dokumen berjaya dimuat naik.`,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik dokumen.')
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateActivity(activityId.value, {
|
||||
activity_type_id: form.activity_type_id,
|
||||
title: form.title.trim(),
|
||||
reference_number: form.reference_number.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
start_datetime: form.start_datetime || null,
|
||||
end_datetime: form.end_datetime || null,
|
||||
organizer: form.organizer.trim() || null,
|
||||
location: form.location.trim() || null,
|
||||
is_active: form.is_active,
|
||||
})
|
||||
|
||||
successMessage.value = 'Aktiviti berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'view-activity', params: { id: activityId.value } })
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini aktiviti.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGalleryImage(imageId: string) {
|
||||
if (deletingAssetId.value || uploadingGallery.value) return
|
||||
|
||||
deletingAssetId.value = imageId
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await deleteActivityGalleryImage(activityId.value, imageId)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
showTransientSuccess('Imej galeri berjaya dipadam.')
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam imej galeri.')
|
||||
} finally {
|
||||
deletingAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument(documentId: string) {
|
||||
if (deletingAssetId.value || uploadingDocuments.value) return
|
||||
|
||||
deletingAssetId.value = documentId
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await deleteActivityDocument(activityId.value, documentId)
|
||||
activity.value = data
|
||||
syncFormFromActivity(data)
|
||||
showTransientSuccess('Dokumen berjaya dipadam.')
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam dokumen.')
|
||||
} finally {
|
||||
deletingAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'view-activity', params: { id: activityId.value } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Aktiviti</h2>
|
||||
<Button look="outline" variant="secondary" type="button" @click="goBack">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan maklumat aktiviti...</div>
|
||||
|
||||
<form v-else-if="activity" class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-title">Tajuk</FieldLabel>
|
||||
<Input id="activity-title" v-model="form.title" required :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<ActivityTypeSelectField v-model="form.activity_type_id" :disabled="saving" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="activeStatusInitial[0]"
|
||||
class="w-full"
|
||||
:collection="activeStatusCollection"
|
||||
:default-value="activeStatusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setActiveStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in activeStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-reference">No. Rujukan</FieldLabel>
|
||||
<Input
|
||||
id="activity-reference"
|
||||
v-model="form.reference_number"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-organizer">Penganjur</FieldLabel>
|
||||
<Input id="activity-organizer" v-model="form.organizer" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-location">Lokasi</FieldLabel>
|
||||
<Input id="activity-location" v-model="form.location" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-start">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="activity-start"
|
||||
v-model="form.start_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="activity-end">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="activity-end"
|
||||
v-model="form.end_datetime"
|
||||
type="datetime-local"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field class="sm:col-span-2">
|
||||
<FieldLabel for="activity-description">Penerangan</FieldLabel>
|
||||
<Textarea
|
||||
id="activity-description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Galeri Foto</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Imej dimuat naik serta-merta apabila dipilih. Padam imej tanpa perlu simpan borang.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="activity.gallery_images.length"
|
||||
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="image in activity.gallery_images"
|
||||
:key="image.id"
|
||||
class="relative overflow-hidden rounded-lg border border-foreground/10"
|
||||
>
|
||||
<img :src="image.url" :alt="image.name" class="aspect-square w-full object-cover" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2"
|
||||
:disabled="deletingAssetId === image.id || uploadingGallery || saving"
|
||||
@click="handleDeleteGalleryImage(image.id)"
|
||||
>
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-gallery-upload">Tambah Imej Galeri</FieldLabel>
|
||||
<input
|
||||
id="activity-gallery-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="uploadingGallery || saving"
|
||||
@change="onGalleryFilesChange"
|
||||
/>
|
||||
<p v-if="uploadingGallery" class="mt-2 text-sm opacity-70">Memuat naik imej...</p>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="font-medium">Dokumen Lampiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Dokumen dimuat naik serta-merta apabila dipilih. Padam dokumen tanpa perlu simpan borang.
|
||||
</p>
|
||||
|
||||
<div v-if="activity.documents.length" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="document in activity.documents"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-foreground/10 p-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ document.name }}</div>
|
||||
<div class="text-xs opacity-70">{{ document.mime_type }}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
size="sm"
|
||||
:disabled="deletingAssetId === document.id || uploadingDocuments || saving"
|
||||
@click="handleDeleteDocument(document.id)"
|
||||
>
|
||||
<Trash class="mr-2 size-4" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="activity-documents-upload">Tambah Dokumen</FieldLabel>
|
||||
<input
|
||||
id="activity-documents-upload"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,application/pdf,image/*"
|
||||
multiple
|
||||
:class="cn(inputStyles)"
|
||||
:disabled="uploadingDocuments || saving"
|
||||
@change="onDocumentFilesChange"
|
||||
/>
|
||||
<p v-if="uploadingDocuments" class="mt-2 text-sm opacity-70">Memuat naik dokumen...</p>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<ActivityReportsSection
|
||||
v-if="activity"
|
||||
:activity-id="activity.id"
|
||||
:reports="activity.reports ?? []"
|
||||
@changed="loadActivity"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="goBack">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
} from '@/components/ui/menu'
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationRoot,
|
||||
PaginationItem,
|
||||
PaginationPrevTrigger,
|
||||
PaginationNextTrigger,
|
||||
PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useActivityList } from '../composables/useActivityList'
|
||||
import type { ActivityListItem } from '../types/activity.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const PER_PAGE_OPTIONS: SelectOption[] = [
|
||||
{ label: '10', value: '10' },
|
||||
{ label: '25', value: '25' },
|
||||
{ label: '35', value: '35' },
|
||||
{ label: '50', value: '50' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tarikh Mula (Terbaharu)', value: 'start_datetime:desc' },
|
||||
{ label: 'Tarikh Mula (Terlama)', value: 'start_datetime:asc' },
|
||||
{ label: 'Tajuk (A-Z)', value: 'title:asc' },
|
||||
{ label: 'Tajuk (Z-A)', value: 'title:desc' },
|
||||
{ label: 'Dicipta (Terbaharu)', value: 'created_at:desc' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const perPageCollection = createSelectCollection(PER_PAGE_OPTIONS)
|
||||
const sortCollection = createSelectCollection(SORT_OPTIONS)
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
const canCreate = computed(() => hasPermission('tambah aktiviti'))
|
||||
const canEdit = computed(() => hasPermission('kemaskini aktiviti'))
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
activities,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination,
|
||||
} = useActivityList()
|
||||
|
||||
const perPageInitial = computed(() => valueToLabel(PER_PAGE_OPTIONS, String(itemsPerPage.value)))
|
||||
const sortInitial = computed(() => valueToLabel(SORT_OPTIONS, `${sortBy.value}:${sortOrder.value}`))
|
||||
|
||||
function handlePageChange(details: { page: number }) {
|
||||
page.value = details.page
|
||||
}
|
||||
|
||||
function setPerPageValue(details: { value: string[] }) {
|
||||
const next = Number(labelToValue(PER_PAGE_OPTIONS, details.value[0]))
|
||||
if (!Number.isNaN(next)) {
|
||||
itemsPerPage.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function setSortValue(details: { value: string[] }) {
|
||||
const raw = labelToValue(SORT_OPTIONS, details.value[0])
|
||||
const [field, order] = raw.split(':')
|
||||
if (field) sortBy.value = field
|
||||
if (order === 'asc' || order === 'desc') sortOrder.value = order
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function coverImageUrl(activity: ActivityListItem): string | null {
|
||||
return activity.cover_image?.url ?? null
|
||||
}
|
||||
|
||||
function truncateText(value: string | null, length = 140): string {
|
||||
if (!value) return ''
|
||||
return value.length > length ? `${value.slice(0, length).trim()}...` : value
|
||||
}
|
||||
|
||||
function goToActivity(id: string) {
|
||||
router.push({ name: 'view-activity', params: { id } })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-activity', params: { id } })
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-activity' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Senarai Aktiviti</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan lihat aktiviti koperasi.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canCreate"
|
||||
look="outline"
|
||||
class="mr-2 shadow-sm"
|
||||
variant="primary"
|
||||
@click="goToCreate"
|
||||
>
|
||||
Tambah Aktiviti
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Input v-model="search" class="pr-10" type="search" placeholder="Cari aktiviti..." />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 size-4 opacity-70" icon="Search" />
|
||||
</div>
|
||||
|
||||
<SelectRoot
|
||||
:key="sortInitial[0]"
|
||||
class="w-full sm:w-56"
|
||||
:collection="sortCollection"
|
||||
:default-value="sortInitial"
|
||||
@value-change="setSortValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Susunan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in sortCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<Box
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
class="col-span-12 animate-pulse p-0 md:col-span-6 xl:col-span-4"
|
||||
>
|
||||
<div class="h-64 bg-foreground/5" />
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Box
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="col-span-12 cursor-pointer p-0 md:col-span-6 xl:col-span-4"
|
||||
@click="goToActivity(activity.id)"
|
||||
>
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-4">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
<Lucide class="size-5" icon="CalendarDays" />
|
||||
</div>
|
||||
<div class="ml-3 mr-auto min-w-0">
|
||||
<div class="truncate font-medium">
|
||||
{{ activity.activity_type?.name ?? 'Aktiviti' }}
|
||||
</div>
|
||||
<div class="mt-0.5 flex truncate text-xs opacity-70">
|
||||
<span v-if="activity.reference_number" class="text-primary inline-block truncate">
|
||||
{{ activity.reference_number }}
|
||||
</span>
|
||||
<span v-if="activity.reference_number" class="mx-1">•</span>
|
||||
<span>{{ formatDateTime(activity.start_datetime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge look="outline" :variant="activity.is_active ? 'success' : 'outline'">
|
||||
{{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="image-fit h-40 overflow-hidden rounded-md bg-foreground/5 2xl:h-56">
|
||||
<img
|
||||
v-if="coverImageUrl(activity)"
|
||||
class="size-full object-cover"
|
||||
:src="coverImageUrl(activity)!"
|
||||
:alt="activity.title"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Lucide class="size-8 opacity-40" icon="Image" />
|
||||
<span class="text-xs opacity-70">Tiada imej</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 block text-base font-medium">
|
||||
{{ activity.title }}
|
||||
</div>
|
||||
<div v-if="activity.description" class="mt-2 line-clamp-3 text-foreground/70">
|
||||
{{ truncateText(activity.description) }}
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs opacity-70">
|
||||
<div v-if="activity.organizer" class="flex items-center gap-2">
|
||||
<Lucide class="size-3.5 shrink-0" icon="Users" />
|
||||
<span class="truncate">{{ activity.organizer }}</span>
|
||||
</div>
|
||||
<div v-if="activity.location" class="flex items-center gap-2">
|
||||
<Lucide class="size-3.5 shrink-0" icon="MapPin" />
|
||||
<span class="truncate">{{ activity.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center border-t border-foreground/15 px-5 py-3">
|
||||
<div class="mr-2 flex items-center gap-1 text-xs opacity-70">
|
||||
<Lucide class="size-3.5" icon="Images" />
|
||||
<span>{{ activity.gallery_image_count }} imej</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs opacity-70">
|
||||
<Lucide class="size-3.5" icon="Paperclip" />
|
||||
<span>{{ activity.documents_count }} dokumen</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto w-auto" @click.stop>
|
||||
<MenuRoot>
|
||||
<MenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreVertical" />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="view" @click="goToActivity(activity.id)">
|
||||
<Lucide class="mr-2 size-4" icon="Eye" /> Lihat
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
v-if="canEdit"
|
||||
value="edit"
|
||||
@click="goToEdit(activity.id)"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Edit" /> Edit
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading && !activities.length" class="col-span-12">
|
||||
<Box class="p-6 text-center opacity-70">Tiada aktiviti ditemui.</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot
|
||||
:count="pagination.total"
|
||||
:page="page"
|
||||
:page-size="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
:on-page-change="handlePageChange"
|
||||
class="w-full sm:mr-auto sm:w-auto"
|
||||
>
|
||||
<PaginationPrevTrigger>
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</PaginationPrevTrigger>
|
||||
<PaginationContext v-slot="{ pagination: paginationApi }">
|
||||
<template v-for="(pageItem, index) in paginationApi?.pages" :key="index">
|
||||
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }">
|
||||
{{ pageItem.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else :index="index" />
|
||||
</template>
|
||||
</PaginationContext>
|
||||
<PaginationNextTrigger>
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</PaginationNextTrigger>
|
||||
</PaginationRoot>
|
||||
|
||||
<SelectRoot
|
||||
:key="perPageInitial[0]"
|
||||
class="mt-3 w-20 sm:mt-0"
|
||||
:collection="perPageCollection"
|
||||
:default-value="perPageInitial"
|
||||
@value-change="setPerPageValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in perPageCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const activityLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'activities',
|
||||
name: 'list-activities',
|
||||
component: () => import('./pages/ActivityList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/create',
|
||||
name: 'create-activity',
|
||||
component: () => import('./pages/ActivityCreate.vue'),
|
||||
meta: {
|
||||
title: 'Daftar Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'tambah aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/:id',
|
||||
name: 'view-activity',
|
||||
component: () => import('./pages/ActivityDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'lihat aktiviti',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'activities/:id/edit',
|
||||
name: 'edit-activity',
|
||||
component: () => import('./pages/ActivityEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Aktiviti',
|
||||
module: 'activity',
|
||||
permission: 'kemaskini aktiviti',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
ActivityDetail,
|
||||
ActivityListItem,
|
||||
ActivityReport,
|
||||
ActivityType,
|
||||
CreateActivityReportPayload,
|
||||
CreateActivityPayload,
|
||||
CreateActivityTypePayload,
|
||||
ListActivitiesParams,
|
||||
UpdateActivityFiles,
|
||||
UpdateActivityPayload,
|
||||
UpdateActivityReportPayload,
|
||||
} from '../types/activity.types'
|
||||
|
||||
type ActivityApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ActivityReportApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityReport
|
||||
message?: string
|
||||
}
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildActivityFormData(
|
||||
payload: UpdateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
options: { method?: 'PUT' } = {},
|
||||
): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('activity_type_id', payload.activity_type_id)
|
||||
formData.append('title', payload.title)
|
||||
formData.append('is_active', payload.is_active ? '1' : '0')
|
||||
appendIfPresent(formData, 'reference_number', payload.reference_number ?? undefined)
|
||||
appendIfPresent(formData, 'description', payload.description ?? undefined)
|
||||
appendIfPresent(formData, 'start_datetime', payload.start_datetime ?? undefined)
|
||||
appendIfPresent(formData, 'end_datetime', payload.end_datetime ?? undefined)
|
||||
appendIfPresent(formData, 'organizer', payload.organizer ?? undefined)
|
||||
appendIfPresent(formData, 'location', payload.location ?? undefined)
|
||||
|
||||
files.documents?.forEach((file, index) => {
|
||||
formData.append(`documents[${index}]`, file)
|
||||
})
|
||||
|
||||
files.gallery?.forEach((file, index) => {
|
||||
formData.append(`gallery[${index}]`, file)
|
||||
})
|
||||
|
||||
if (options.method === 'PUT') {
|
||||
formData.append('_method', 'PUT')
|
||||
}
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
export async function listActivities(
|
||||
params: ListActivitiesParams,
|
||||
): Promise<PaginatedApiResponse<ActivityListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityListItem>>('/v1/activities', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai aktiviti.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listActivityTypes(): Promise<ActivityType[]> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityType>>('/v1/activity-types', {
|
||||
params: { per_page: 1000 },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan jenis aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
type ActivityTypeApiResponse = {
|
||||
success: boolean
|
||||
data: ActivityType
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function createActivityType(
|
||||
payload: CreateActivityTypePayload,
|
||||
): Promise<ActivityType> {
|
||||
const { data } = await api.post<ActivityTypeApiResponse>('/v1/activity-types', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah jenis aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getActivity(id: string): Promise<ActivityDetail> {
|
||||
const { data } = await api.get<ActivityApiResponse>(`/v1/activities/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan butiran aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateActivity(
|
||||
id: string,
|
||||
payload: UpdateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = buildActivityFormData(payload, files, { method: 'PUT' })
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(`/v1/activities/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createActivity(
|
||||
payload: CreateActivityPayload,
|
||||
files: UpdateActivityFiles = {},
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = buildActivityFormData(payload, files)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>('/v1/activities', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mendaftar aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadActivityGalleryImage(
|
||||
activityId: string,
|
||||
file: File,
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/gallery`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik imej galeri.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadActivityDocument(
|
||||
activityId: string,
|
||||
file: File,
|
||||
): Promise<ActivityDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const { data } = await api.post<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/documents`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik dokumen.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteActivityGalleryImage(
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
): Promise<ActivityDetail> {
|
||||
const { data } = await api.delete<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/gallery/${imageId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam imej galeri.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
): Promise<ActivityDetail> {
|
||||
const { data } = await api.delete<ActivityApiResponse>(
|
||||
`/v1/activities/${activityId}/documents/${documentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam dokumen.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchActivityGalleryImage(
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(`/v1/activities/${activityId}/gallery/${imageId}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function fetchActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(
|
||||
`/v1/activities/${activityId}/documents/${documentId}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
|
||||
export async function downloadActivityDocument(
|
||||
activityId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<void> {
|
||||
const blob = await fetchActivityDocument(activityId, documentId, mimeType)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function createActivityReport(
|
||||
payload: CreateActivityReportPayload,
|
||||
): Promise<ActivityReport> {
|
||||
const { data } = await api.post<ActivityReportApiResponse>('/v1/activity-reports', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah laporan aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateActivityReport(
|
||||
id: string,
|
||||
payload: UpdateActivityReportPayload,
|
||||
): Promise<ActivityReport> {
|
||||
const { data } = await api.put<ActivityReportApiResponse>(`/v1/activity-reports/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini laporan aktiviti.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
type ActivityDeleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function deleteActivity(id: string): Promise<void> {
|
||||
const { data } = await api.delete<ActivityDeleteResponse>(`/v1/activities/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam aktiviti.')
|
||||
}
|
||||
}
|
||||
|
||||
type ActivityReportDeleteResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function deleteActivityReport(id: string): Promise<void> {
|
||||
const { data } = await api.delete<ActivityReportDeleteResponse>(`/v1/activity-reports/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam laporan aktiviti.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type ActivityType = {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CreateActivityTypePayload = {
|
||||
name: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
export type ActivityGalleryImage = {
|
||||
id: string
|
||||
activity_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
mime_type: string
|
||||
file_size: number
|
||||
uploaded_by: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ActivityDocument = {
|
||||
id: string
|
||||
activity_id: string
|
||||
name: string
|
||||
type: string | null
|
||||
description: string | null
|
||||
mime_type: string
|
||||
file_size: number
|
||||
uploaded_by: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ActivityReport = {
|
||||
id: string
|
||||
activity_id: string
|
||||
report_text: string
|
||||
prepared_by: string
|
||||
prepared_by_user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
} | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActivityDetail = {
|
||||
id: string
|
||||
activity_type_id: string
|
||||
activity_type: ActivityType | null
|
||||
title: string
|
||||
reference_number: string | null
|
||||
description: string | null
|
||||
start_datetime: string | null
|
||||
end_datetime: string | null
|
||||
organizer: string | null
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
documents: ActivityDocument[]
|
||||
gallery_images: ActivityGalleryImage[]
|
||||
reports: ActivityReport[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActivityListItem = {
|
||||
id: string
|
||||
activity_type_id: string
|
||||
activity_type: ActivityType | null
|
||||
title: string
|
||||
reference_number: string | null
|
||||
description: string | null
|
||||
start_datetime: string | null
|
||||
end_datetime: string | null
|
||||
organizer: string | null
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
gallery_image_count: number
|
||||
documents_count: number
|
||||
cover_image: ActivityGalleryImage | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ListActivitiesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type UpdateActivityPayload = {
|
||||
activity_type_id: string
|
||||
title: string
|
||||
reference_number?: string | null
|
||||
description?: string | null
|
||||
start_datetime?: string | null
|
||||
end_datetime?: string | null
|
||||
organizer?: string | null
|
||||
location?: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export type CreateActivityPayload = UpdateActivityPayload
|
||||
|
||||
export type UpdateActivityFiles = {
|
||||
documents?: File[]
|
||||
gallery?: File[]
|
||||
}
|
||||
|
||||
export type CreateActivityReportPayload = {
|
||||
activity_id: string
|
||||
report_text: string
|
||||
}
|
||||
|
||||
export type UpdateActivityReportPayload = {
|
||||
activity_id: string
|
||||
report_text: string
|
||||
}
|
||||
Reference in New Issue
Block a user