406 lines
12 KiB
Vue
406 lines
12 KiB
Vue
<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>
|