325 lines
10 KiB
Vue
325 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
|
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
|
|
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
|
|
|
|
interface Props {
|
|
config: ListViewConfig
|
|
data?: any[]
|
|
loading?: boolean
|
|
selectable?: boolean
|
|
baseUrl?: string
|
|
totalCount?: number
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
data: () => [],
|
|
loading: false,
|
|
selectable: false,
|
|
baseUrl: '/runtime/objects',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'row-click': [row: any]
|
|
'row-select': [rows: any[]]
|
|
'create': []
|
|
'edit': [row: any]
|
|
'delete': [rows: any[]]
|
|
'action': [actionId: string, rows: any[]]
|
|
'sort': [field: string, direction: 'asc' | 'desc']
|
|
'search': [query: string]
|
|
'refresh': []
|
|
'page-change': [page: number, pageSize: number]
|
|
'load-more': [page: number, pageSize: number]
|
|
}>()
|
|
|
|
// State
|
|
const selectedRows = ref<Set<string>>(new Set())
|
|
const searchQuery = ref('')
|
|
const sortField = ref<string>('')
|
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
const currentPage = ref(1)
|
|
|
|
// Computed
|
|
const visibleFields = computed(() =>
|
|
props.config.fields.filter(f => f.showOnList !== false)
|
|
)
|
|
|
|
const pageSize = computed(() => props.config.pageSize ?? 10)
|
|
const maxFrontendRecords = computed(() => props.config.maxFrontendRecords ?? 500)
|
|
const totalRecords = computed(() =>
|
|
(props.totalCount && props.totalCount > 0)
|
|
? props.totalCount
|
|
: props.data.length
|
|
)
|
|
const useHybridPagination = computed(() => totalRecords.value > maxFrontendRecords.value)
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalRecords.value / pageSize.value)))
|
|
const loadedPages = computed(() => Math.max(1, Math.ceil(props.data.length / pageSize.value)))
|
|
const availablePages = computed(() => {
|
|
if (useHybridPagination.value && props.totalCount && props.data.length < props.totalCount) {
|
|
return loadedPages.value
|
|
}
|
|
return totalPages.value
|
|
})
|
|
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value)
|
|
const paginatedData = computed(() => {
|
|
const start = startIndex.value
|
|
const end = start + pageSize.value
|
|
return props.data.slice(start, end)
|
|
})
|
|
const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1))
|
|
const pageEnd = computed(() => Math.min(startIndex.value + paginatedData.value.length, totalRecords.value))
|
|
const showPagination = computed(() => totalRecords.value > pageSize.value)
|
|
const canGoPrev = computed(() => currentPage.value > 1)
|
|
const canGoNext = computed(() => currentPage.value < availablePages.value)
|
|
const showLoadMore = computed(() => (
|
|
useHybridPagination.value &&
|
|
Boolean(props.totalCount) &&
|
|
props.data.length < totalRecords.value
|
|
))
|
|
|
|
const allSelected = computed({
|
|
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
|
|
set: (val: boolean) => {
|
|
if (val) {
|
|
selectedRows.value = new Set(props.data.map(row => row.id))
|
|
} else {
|
|
selectedRows.value.clear()
|
|
}
|
|
emit('row-select', getSelectedRows())
|
|
},
|
|
})
|
|
|
|
const getSelectedRows = () => {
|
|
return props.data.filter(row => selectedRows.value.has(row.id))
|
|
}
|
|
|
|
const toggleRowSelection = (rowId: string) => {
|
|
if (selectedRows.value.has(rowId)) {
|
|
selectedRows.value.delete(rowId)
|
|
} else {
|
|
selectedRows.value.add(rowId)
|
|
}
|
|
emit('row-select', getSelectedRows())
|
|
}
|
|
|
|
const handleSort = (field: string) => {
|
|
if (sortField.value === field) {
|
|
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortField.value = field
|
|
sortDirection.value = 'asc'
|
|
}
|
|
emit('sort', field, sortDirection.value)
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
emit('search', searchQuery.value)
|
|
}
|
|
|
|
const handleAction = (actionId: string) => {
|
|
emit('action', actionId, getSelectedRows())
|
|
}
|
|
|
|
const goToPage = (page: number) => {
|
|
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
|
if (nextPage !== currentPage.value) {
|
|
currentPage.value = nextPage
|
|
emit('page-change', nextPage, pageSize.value)
|
|
}
|
|
}
|
|
|
|
const loadMore = () => {
|
|
const nextPage = Math.ceil(props.data.length / pageSize.value) + 1
|
|
emit('load-more', nextPage, pageSize.value)
|
|
}
|
|
|
|
watch(
|
|
() => [props.data.length, totalRecords.value, pageSize.value],
|
|
() => {
|
|
if (currentPage.value > availablePages.value) {
|
|
currentPage.value = availablePages.value
|
|
}
|
|
}
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="list-view space-y-4">
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center justify-between gap-4">
|
|
<!-- Search -->
|
|
<div v-if="config.searchable !== false" class="flex-1 max-w-sm">
|
|
<div class="relative">
|
|
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
v-model="searchQuery"
|
|
placeholder="Search..."
|
|
class="pl-8"
|
|
@keyup.enter="handleSearch"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<!-- Bulk Actions -->
|
|
<template v-if="selectedRows.size > 0">
|
|
<Badge variant="secondary" class="px-3 py-1">
|
|
{{ selectedRows.size }} selected
|
|
</Badge>
|
|
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
|
|
<Trash2 class="h-4 w-4 mr-2" />
|
|
Delete
|
|
</Button>
|
|
</template>
|
|
|
|
<!-- Custom Actions -->
|
|
<Button
|
|
v-for="action in config.actions"
|
|
:key="action.id"
|
|
:variant="action.variant || 'outline'"
|
|
size="sm"
|
|
@click="handleAction(action.id)"
|
|
>
|
|
{{ action.label }}
|
|
</Button>
|
|
|
|
<!-- Export -->
|
|
<Button v-if="config.exportable" variant="outline" size="sm">
|
|
<Download class="h-4 w-4 mr-2" />
|
|
Export
|
|
</Button>
|
|
|
|
<!-- Create -->
|
|
<Button size="sm" @click="emit('create')">
|
|
<Plus class="h-4 w-4 mr-2" />
|
|
New
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="border rounded-lg">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead v-if="selectable" class="w-12">
|
|
<Checkbox v-model:checked="allSelected" />
|
|
</TableHead>
|
|
<TableHead
|
|
v-for="field in visibleFields"
|
|
:key="field.id"
|
|
:class="{ 'cursor-pointer hover:bg-muted/50': field.sortable !== false }"
|
|
@click="field.sortable !== false && handleSort(field.apiName)"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
{{ field.label }}
|
|
<template v-if="field.sortable !== false && sortField === field.apiName">
|
|
<ChevronUp v-if="sortDirection === 'asc'" class="h-4 w-4" />
|
|
<ChevronDown v-else class="h-4 w-4" />
|
|
</template>
|
|
</div>
|
|
</TableHead>
|
|
<TableHead class="w-20">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<TableRow v-if="loading">
|
|
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8">
|
|
<div class="flex items-center justify-center">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow v-else-if="data.length === 0">
|
|
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8 text-muted-foreground">
|
|
No records found
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow
|
|
v-else
|
|
v-for="row in paginatedData"
|
|
:key="row.id"
|
|
class="cursor-pointer hover:bg-muted/50"
|
|
@click="emit('row-click', row)"
|
|
>
|
|
<TableCell v-if="selectable" @click.stop>
|
|
<Checkbox
|
|
:checked="selectedRows.has(row.id)"
|
|
@update:checked="toggleRowSelection(row.id)"
|
|
/>
|
|
</TableCell>
|
|
<TableCell v-for="field in visibleFields" :key="field.id">
|
|
<FieldRenderer
|
|
:field="field"
|
|
:model-value="row[field.apiName]"
|
|
:record-data="row"
|
|
:mode="ViewMode.LIST"
|
|
:base-url="baseUrl"
|
|
/>
|
|
</TableCell>
|
|
<TableCell @click.stop>
|
|
<div class="flex items-center gap-1">
|
|
<Button variant="ghost" size="sm" @click="emit('edit', row)">
|
|
<Edit class="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" @click="emit('delete', [row])">
|
|
<Trash2 class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
<div class="flex items-center gap-2">
|
|
<span>Showing {{ pageStart }}-{{ pageEnd }} of {{ totalRecords }} records</span>
|
|
<span v-if="showLoadMore">
|
|
(loaded {{ data.length }})
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Button variant="outline" size="sm" :disabled="!canGoPrev" @click="goToPage(currentPage - 1)">
|
|
Previous
|
|
</Button>
|
|
<span class="px-2">Page {{ currentPage }} of {{ totalPages }}</span>
|
|
<Button variant="outline" size="sm" :disabled="!canGoNext" @click="goToPage(currentPage + 1)">
|
|
Next
|
|
</Button>
|
|
<Button v-if="showLoadMore" variant="secondary" size="sm" @click="loadMore">
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.list-view {
|
|
width: 100%;
|
|
}
|
|
|
|
.list-view :deep(.border) {
|
|
background-color: hsl(var(--card));
|
|
}
|
|
|
|
.list-view :deep(input) {
|
|
background-color: hsl(var(--background));
|
|
}
|
|
</style>
|