DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user