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