Files
erahn-v2-fe/src/modules/activity/pages/ActivityList.vue
T

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>