Files
My-KOPKB/fe/src/modules/activity/pages/ActivityCreate.vue
T

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>