686 lines
22 KiB
Vue
686 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
|
import { AgGridVue } from 'ag-grid-vue3'
|
|
import 'ag-grid-community/styles/ag-grid.css'
|
|
import 'ag-grid-community/styles/ag-theme-alpine.css'
|
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
|
import { ListViewConfig, ViewMode, FieldType, FieldConfig } from '@/types/field-types'
|
|
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit, Bookmark, BookmarkPlus, Settings2 } from 'lucide-vue-next'
|
|
import type { SavedView } from '@/composables/useSavedViews'
|
|
|
|
interface Props {
|
|
config: ListViewConfig
|
|
data?: any[]
|
|
loading?: boolean
|
|
selectable?: boolean
|
|
baseUrl?: string
|
|
totalCount?: number
|
|
searchSummary?: string
|
|
draftEdits?: Record<string, Record<string, any>>
|
|
cellErrors?: Record<string, Record<string, string | boolean>>
|
|
savingDrafts?: boolean
|
|
// Saved views
|
|
savedViews?: SavedView[]
|
|
activeViewId?: string | null
|
|
currentSearchPlan?: { strategy: string; filters: any[]; sort: any; explanation: string } | null
|
|
savingView?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
data: () => [],
|
|
loading: false,
|
|
selectable: false,
|
|
baseUrl: '/runtime/objects',
|
|
searchSummary: '',
|
|
draftEdits: () => ({}),
|
|
cellErrors: () => ({}),
|
|
savingDrafts: false,
|
|
savedViews: () => [],
|
|
activeViewId: null,
|
|
currentSearchPlan: null,
|
|
savingView: false,
|
|
})
|
|
|
|
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]
|
|
'view-change': [mode: 'list' | 'spreadsheet']
|
|
'cell-edit': [payload: { row: any; field: FieldConfig; newValue: any; oldValue: any }]
|
|
'save-drafts': []
|
|
'discard-drafts': []
|
|
// Saved views
|
|
'apply-view': [view: SavedView]
|
|
'save-view': []
|
|
'open-view-manager': []
|
|
}>()
|
|
|
|
// State
|
|
const normalizeId = (id: any) => String(id)
|
|
const selectedRowIds = ref<string[]>([])
|
|
const searchQuery = ref('')
|
|
const sortField = ref<string>('')
|
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
const currentPage = ref(1)
|
|
const bulkAction = ref('delete')
|
|
const viewMode = ref<'list' | 'spreadsheet'>('list')
|
|
const gridApi = ref<GridApi | null>(null)
|
|
|
|
// 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(() => viewMode.value === 'list' && 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 draftRowCount = computed(() => Object.keys(props.draftEdits).length)
|
|
const draftCellCount = computed(() => Object.values(props.draftEdits).reduce((sum, row) => sum + Object.keys(row).length, 0))
|
|
|
|
const allSelected = computed({
|
|
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
|
|
set: (val: boolean) => {
|
|
if (val) {
|
|
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
|
|
} else {
|
|
selectedRowIds.value = []
|
|
}
|
|
emit('row-select', getSelectedRows())
|
|
},
|
|
})
|
|
|
|
const getSelectedRows = () => {
|
|
const idSet = new Set(selectedRowIds.value)
|
|
return props.data.filter(row => idSet.has(normalizeId(row.id)))
|
|
}
|
|
|
|
const toggleRowSelection = (rowId: string) => {
|
|
const normalizedId = normalizeId(rowId)
|
|
const nextSelection = new Set(selectedRowIds.value)
|
|
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
|
|
selectedRowIds.value = Array.from(nextSelection)
|
|
emit('row-select', getSelectedRows())
|
|
}
|
|
|
|
const setRowSelection = (rowId: string, checked: boolean) => {
|
|
const normalizedId = normalizeId(rowId)
|
|
const nextSelection = new Set(selectedRowIds.value)
|
|
if (checked) {
|
|
nextSelection.add(normalizedId)
|
|
} else {
|
|
nextSelection.delete(normalizedId)
|
|
}
|
|
selectedRowIds.value = Array.from(nextSelection)
|
|
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 handleBulkAction = () => {
|
|
if (bulkAction.value === 'delete') {
|
|
emit('delete', getSelectedRows())
|
|
return
|
|
}
|
|
emit('action', bulkAction.value, getSelectedRows())
|
|
}
|
|
|
|
const isEditableField = (field: FieldConfig) =>
|
|
field.showOnEdit !== false &&
|
|
!field.isReadOnly &&
|
|
![FieldType.BELONGS_TO, FieldType.HAS_MANY, FieldType.MANY_TO_MANY].includes(field.type)
|
|
|
|
const getRelationPropertyName = (apiName: string) => apiName.replace(/Id$/, '').toLowerCase()
|
|
|
|
const formatFieldValue = (field: FieldConfig, value: any, record?: any) => {
|
|
if (value === null || value === undefined) return ''
|
|
switch (field.type) {
|
|
case FieldType.BELONGS_TO: {
|
|
const relationPropertyName = getRelationPropertyName(field.apiName)
|
|
const relatedObject = record?.[relationPropertyName]
|
|
if (relatedObject && typeof relatedObject === 'object') {
|
|
const displayField = field.relationDisplayField || 'name'
|
|
return relatedObject[displayField] || relatedObject.id || value
|
|
}
|
|
return value
|
|
}
|
|
case FieldType.DATE: {
|
|
const date = value instanceof Date ? value : new Date(value)
|
|
return Number.isNaN(date.getTime())
|
|
? String(value)
|
|
: date.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
}
|
|
case FieldType.DATETIME: {
|
|
const date = value instanceof Date ? value : new Date(value)
|
|
return Number.isNaN(date.getTime())
|
|
? String(value)
|
|
: date.toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
})
|
|
}
|
|
case FieldType.BOOLEAN:
|
|
return value ? 'Yes' : 'No'
|
|
case FieldType.CURRENCY:
|
|
return `${field.prefix || '$'}${Number(value).toFixed(2)}${field.suffix || ''}`
|
|
case FieldType.SELECT: {
|
|
const option = field.options?.find(opt => opt.value === value)
|
|
return option?.label || value
|
|
}
|
|
case FieldType.MULTI_SELECT:
|
|
return Array.isArray(value)
|
|
? value
|
|
.map(v => field.options?.find(opt => opt.value === v)?.label || v)
|
|
.join(', ')
|
|
: ''
|
|
default:
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
const hasOwnProperty = (target: Record<string, any> | undefined, key: string) =>
|
|
!!target && Object.prototype.hasOwnProperty.call(target, key)
|
|
|
|
const isDraftCell = (params: any) => {
|
|
const rowId = normalizeId(params?.data?.id)
|
|
const field = String(params?.colDef?.field || '')
|
|
return hasOwnProperty(props.draftEdits[rowId], field)
|
|
}
|
|
|
|
const isErrorCell = (params: any) => {
|
|
const rowId = normalizeId(params?.data?.id)
|
|
const field = String(params?.colDef?.field || '')
|
|
return hasOwnProperty(props.cellErrors[rowId], field)
|
|
}
|
|
|
|
const getCellClasses = (params: any) => {
|
|
const classes: string[] = []
|
|
if (isDraftCell(params)) classes.push('cell-draft')
|
|
if (isErrorCell(params)) classes.push('cell-error')
|
|
return classes
|
|
}
|
|
|
|
const getCellStyle = (params: any) => {
|
|
const isDraft = isDraftCell(params)
|
|
const isError = isErrorCell(params)
|
|
if (!isDraft && !isError) return null
|
|
const style: Record<string, string> = {}
|
|
if (isDraft) {
|
|
style.backgroundColor = 'hsl(var(--accent) / 0.45)'
|
|
style.outline = '2px solid hsl(var(--accent))'
|
|
style.outlineOffset = '-2px'
|
|
}
|
|
if (isError) {
|
|
style.backgroundColor = 'hsl(var(--destructive) / 0.28)'
|
|
style.outline = '2px solid hsl(var(--destructive))'
|
|
style.outlineOffset = '-2px'
|
|
}
|
|
return style
|
|
}
|
|
|
|
const columnDefs = computed<ColDef[]>(() =>
|
|
visibleFields.value.map(field => ({
|
|
field: field.apiName,
|
|
headerName: field.label,
|
|
sortable: field.sortable !== false,
|
|
editable: isEditableField(field),
|
|
valueFormatter: params => formatFieldValue(field, params.value, params.data),
|
|
cellClass: params => getCellClasses(params),
|
|
cellStyle: params => getCellStyle(params),
|
|
cellEditor:
|
|
field.type === FieldType.SELECT
|
|
? 'agSelectCellEditor'
|
|
: field.type === FieldType.MULTI_SELECT
|
|
? 'agTextCellEditor'
|
|
: field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
|
|
? 'agTextCellEditor'
|
|
: undefined,
|
|
cellEditorParams:
|
|
field.type === FieldType.SELECT
|
|
? { values: (field.options || []).map(opt => opt.value) }
|
|
: undefined,
|
|
valueParser:
|
|
field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
|
|
? params => (params.newValue === '' ? null : Number(params.newValue))
|
|
: undefined,
|
|
flex: 1,
|
|
minWidth: 160,
|
|
}))
|
|
)
|
|
|
|
const defaultColDef: ColDef = {
|
|
resizable: true,
|
|
sortable: true,
|
|
}
|
|
|
|
const handleCellValueChanged = (event: CellValueChangedEvent) => {
|
|
const field = visibleFields.value.find(item => item.apiName === event.colDef.field)
|
|
if (!field || event.newValue === event.oldValue) return
|
|
emit('cell-edit', {
|
|
row: event.data,
|
|
field,
|
|
newValue: event.newValue,
|
|
oldValue: event.oldValue,
|
|
})
|
|
}
|
|
|
|
const handleGridReady = (event: GridReadyEvent) => {
|
|
gridApi.value = event.api
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => props.data,
|
|
(rows) => {
|
|
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
|
|
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
|
|
if (nextSelection.length !== selectedRowIds.value.length) {
|
|
selectedRowIds.value = nextSelection
|
|
emit('row-select', getSelectedRows())
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(
|
|
() => viewMode.value,
|
|
(mode) => {
|
|
emit('view-change', mode)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => [props.draftEdits, props.cellErrors],
|
|
() => {
|
|
if (gridApi.value) {
|
|
gridApi.value.refreshCells({ force: true })
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
</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>
|
|
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
|
|
{{ searchSummary }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<!-- Saved Views dropdown + cog -->
|
|
<div class="flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<Button variant="outline" size="sm" class="gap-2">
|
|
<Bookmark class="h-4 w-4" />
|
|
<span class="max-w-[120px] truncate">
|
|
{{ savedViews.find(v => v.id === activeViewId)?.name || 'Views' }}
|
|
</span>
|
|
<ChevronDown class="h-3 w-3 opacity-60" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" class="w-56">
|
|
<DropdownMenuItem
|
|
v-if="savedViews.length === 0"
|
|
disabled
|
|
class="text-muted-foreground"
|
|
>
|
|
No saved views yet
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
v-for="view in savedViews"
|
|
:key="view.id"
|
|
:class="{ 'font-medium': view.id === activeViewId }"
|
|
@click="emit('apply-view', view)"
|
|
>
|
|
<span class="flex-1 truncate">{{ view.name }}</span>
|
|
<Badge v-if="view.isShared" variant="secondary" class="ml-2 text-[10px] px-1.5 py-0">Shared</Badge>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator v-if="savedViews.length > 0" />
|
|
<DropdownMenuItem @click="emit('open-view-manager')">
|
|
Manage views…
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-8 w-8"
|
|
title="Manage saved views"
|
|
@click="emit('open-view-manager')"
|
|
>
|
|
<Settings2 class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Save current search as a view (only for query strategy) -->
|
|
<Button
|
|
v-if="currentSearchPlan?.strategy === 'query'"
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="savingView"
|
|
class="gap-2"
|
|
@click="emit('save-view')"
|
|
>
|
|
<BookmarkPlus class="h-4 w-4" />
|
|
Save view
|
|
</Button>
|
|
|
|
<Select v-model="viewMode">
|
|
<SelectTrigger class="h-8 w-[180px]">
|
|
<SelectValue placeholder="Select view" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="list">List view</SelectItem>
|
|
<SelectItem value="spreadsheet">Spreadsheet view</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<template v-if="viewMode === 'spreadsheet'">
|
|
<Badge v-if="draftRowCount > 0" variant="secondary" class="px-3 py-1">
|
|
{{ draftCellCount }} change{{ draftCellCount === 1 ? '' : 's' }}
|
|
</Badge>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="draftRowCount === 0 || savingDrafts"
|
|
@click="emit('discard-drafts')"
|
|
>
|
|
Discard changes
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
:disabled="draftRowCount === 0 || savingDrafts"
|
|
@click="emit('save-drafts')"
|
|
>
|
|
Save changes ({{ draftRowCount }})
|
|
</Button>
|
|
</template>
|
|
<!-- Bulk Actions -->
|
|
<template v-if="selectedRowIds.length > 0">
|
|
<Badge variant="secondary" class="px-3 py-1">
|
|
{{ selectedRowIds.length }} selected
|
|
</Badge>
|
|
<div class="flex items-center gap-2">
|
|
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
|
|
<SelectTrigger class="h-8 w-[180px]">
|
|
<SelectValue placeholder="Select action" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="delete">Delete selected</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="sm" @click="handleBulkAction">
|
|
<Trash2 class="h-4 w-4 mr-2" />
|
|
Run
|
|
</Button>
|
|
</div>
|
|
</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 v-if="viewMode === 'list'">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead v-if="selectable" class="w-12">
|
|
<Checkbox
|
|
:model-value="allSelected"
|
|
@update:model-value="(value: boolean) => (allSelected = value)"
|
|
/>
|
|
</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
|
|
:model-value="selectedRowIds.includes(normalizeId(row.id))"
|
|
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
|
|
/>
|
|
</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 v-else class="ag-theme-alpine">
|
|
<AgGridVue
|
|
:row-data="data"
|
|
:column-defs="columnDefs"
|
|
:default-col-def="defaultColDef"
|
|
dom-layout="autoHeight"
|
|
row-selection="multiple"
|
|
suppress-row-click-selection
|
|
single-click-edit
|
|
@cell-value-changed="handleCellValueChanged"
|
|
@grid-ready="handleGridReady"
|
|
/>
|
|
</div>
|
|
</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>
|