@kikiloaw/simple-table 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +731 -0
- package/package.json +32 -0
- package/src/SimpleTable.vue +795 -0
- package/src/components/table/Table.vue +25 -0
- package/src/components/table/TableBody.vue +23 -0
- package/src/components/table/TableCell.vue +23 -0
- package/src/components/table/TableHead.vue +28 -0
- package/src/components/table/TableHeader.vue +20 -0
- package/src/components/table/TableRow.vue +28 -0
- package/src/components/table/index.ts +6 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +33 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
import { computed, ref, watch, onMounted } from 'vue'
|
|
3
|
+
import { router } from '@inertiajs/vue3'
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from './components/table'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Props definition
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
data?: any[] | Record<string, any> // Array for client-side, Object (Paginator) for server-side
|
|
22
|
+
columns: {
|
|
23
|
+
key: string
|
|
24
|
+
label: string
|
|
25
|
+
sortable?: boolean | string // true for default (use key), string for custom backend column name
|
|
26
|
+
class?: string
|
|
27
|
+
fixed?: boolean // 'left' or 'right' could be added later, assuming 'right' for actions usually
|
|
28
|
+
width?: string
|
|
29
|
+
}[]
|
|
30
|
+
mode?: 'auto' | 'server' | 'client'
|
|
31
|
+
protocol?: 'laravel' | 'datatables' // API request/response format
|
|
32
|
+
searchable?: boolean
|
|
33
|
+
perPage?: number
|
|
34
|
+
pageSizes?: any[] // number[] or { label: string, value: number }[]
|
|
35
|
+
fetchUrl?: string
|
|
36
|
+
|
|
37
|
+
// Cache Props
|
|
38
|
+
enableCache?: boolean // If true, cache responses by page/search/sort to avoid redundant requests
|
|
39
|
+
|
|
40
|
+
// Additional Query Parameters
|
|
41
|
+
queryParams?: Record<string, any> // Additional parameters to send with every request (e.g., filters, user context)
|
|
42
|
+
|
|
43
|
+
// Style Props
|
|
44
|
+
oddRowColor?: string // Tailwind color class, e.g. 'bg-white'
|
|
45
|
+
evenRowColor?: string // Tailwind color class, e.g. 'bg-gray-50'
|
|
46
|
+
hoverColor?: string // Tailwind color class for hover, e.g. 'hover:bg-gray-100'. If passed, we'll try to apply group-hover for fixed cols.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
50
|
+
data: () => [],
|
|
51
|
+
mode: 'auto',
|
|
52
|
+
protocol: 'laravel',
|
|
53
|
+
searchable: true,
|
|
54
|
+
enableCache: false,
|
|
55
|
+
queryParams: () => ({}),
|
|
56
|
+
perPage: 10,
|
|
57
|
+
pageSizes: () => [10, 20, 30, 50, 100],
|
|
58
|
+
oddRowColor: 'bg-background',
|
|
59
|
+
evenRowColor: 'bg-background',
|
|
60
|
+
hoverColor: 'hover:bg-muted/50'
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ...
|
|
64
|
+
|
|
65
|
+
// -- Computed: Page Sizes Normalization --
|
|
66
|
+
const normalizedPageSizes = computed(() => {
|
|
67
|
+
if (!props.pageSizes || props.pageSizes.length === 0) return []
|
|
68
|
+
|
|
69
|
+
// Check first item type
|
|
70
|
+
const first = props.pageSizes[0]
|
|
71
|
+
|
|
72
|
+
// If simple numbers [10, 20]
|
|
73
|
+
if (typeof first === 'number' || typeof first === 'string') {
|
|
74
|
+
return props.pageSizes.map(v => ({ label: String(v), value: String(v) }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If objects [{ label: 'All', value: 999 }, { label: '20', value: 20 }]
|
|
78
|
+
if (typeof first === 'object' && 'label' in first && 'value' in first) {
|
|
79
|
+
return props.pageSizes.map(v => ({ label: v.label, value: String(v.value) }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback?
|
|
83
|
+
return []
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// <template>
|
|
89
|
+
// ...
|
|
90
|
+
/*
|
|
91
|
+
<div class="flex items-center gap-2 ml-auto">
|
|
92
|
+
<!-- Mini Export Buttons -->
|
|
93
|
+
<Button
|
|
94
|
+
v-if="exportable || enableCsv"
|
|
95
|
+
variant="outline"
|
|
96
|
+
size="icon"
|
|
97
|
+
class="h-8 w-8"
|
|
98
|
+
title="Export CSV"
|
|
99
|
+
@click="handleExport('csv')"
|
|
100
|
+
>
|
|
101
|
+
<FileText class="h-4 w-4 text-green-600" />
|
|
102
|
+
</Button>
|
|
103
|
+
<Button
|
|
104
|
+
v-if="exportable || enableExcel"
|
|
105
|
+
variant="outline"
|
|
106
|
+
size="icon"
|
|
107
|
+
class="h-8 w-8"
|
|
108
|
+
title="Export Excel"
|
|
109
|
+
@click="handleExport('excel')"
|
|
110
|
+
>
|
|
111
|
+
<Sheet class="h-4 w-4 text-emerald-600" />
|
|
112
|
+
</Button>
|
|
113
|
+
<Button
|
|
114
|
+
v-if="exportable || enablePdf"
|
|
115
|
+
variant="outline"
|
|
116
|
+
size="icon"
|
|
117
|
+
class="h-8 w-8"
|
|
118
|
+
title="Export PDF"
|
|
119
|
+
@click="handleExport('pdf')"
|
|
120
|
+
>
|
|
121
|
+
<!-- Using Download icon for PDF or maybe FileText? Let's use Download for generic or find a PDF-like icon. FileText is close. -->
|
|
122
|
+
<FileText class="h-4 w-4 text-red-600" />
|
|
123
|
+
</Button>
|
|
124
|
+
<slot name="actions" />
|
|
125
|
+
</div>
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
const emit = defineEmits(['update:search', 'update:sort', 'page-change'])
|
|
129
|
+
|
|
130
|
+
// -- State --
|
|
131
|
+
const searchQuery = ref('')
|
|
132
|
+
const sortColumn = ref('')
|
|
133
|
+
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
134
|
+
const currentPage = ref(1)
|
|
135
|
+
const isLoading = ref(false)
|
|
136
|
+
|
|
137
|
+
const currentPerPage = ref(props.perPage)
|
|
138
|
+
const drawCounter = ref(1) // For DataTables protocol
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
// -- Cache System --
|
|
142
|
+
const responseCache = ref<Map<string, any>>(new Map())
|
|
143
|
+
|
|
144
|
+
function getCacheKey(): string {
|
|
145
|
+
// Create a unique key based on current state
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
page: currentPage.value,
|
|
148
|
+
perPage: currentPerPage.value,
|
|
149
|
+
search: searchQuery.value,
|
|
150
|
+
sort: sortColumn.value,
|
|
151
|
+
order: sortDirection.value,
|
|
152
|
+
queryParams: props.queryParams
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearCache() {
|
|
157
|
+
responseCache.value.clear()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Internal data state to handle both props updates and ajax updates
|
|
161
|
+
const internalData = ref(props.data)
|
|
162
|
+
|
|
163
|
+
watch(() => props.data, (newVal) => {
|
|
164
|
+
internalData.value = newVal
|
|
165
|
+
}, { deep: true })
|
|
166
|
+
|
|
167
|
+
// Watch queryParams and refetch when they change
|
|
168
|
+
watch(() => props.queryParams, () => {
|
|
169
|
+
if (isServerSide.value) {
|
|
170
|
+
currentPage.value = 1 // Reset to first page when filters change
|
|
171
|
+
fetchData()
|
|
172
|
+
}
|
|
173
|
+
}, { deep: true })
|
|
174
|
+
|
|
175
|
+
// -- Computed: Mode Detection --
|
|
176
|
+
const isServerSide = computed(() => {
|
|
177
|
+
if (props.mode === 'server') return true
|
|
178
|
+
if (props.mode === 'client') return false
|
|
179
|
+
if (props.fetchUrl) return true
|
|
180
|
+
|
|
181
|
+
// Auto detect
|
|
182
|
+
const d = internalData.value as any
|
|
183
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
184
|
+
if ('current_page' in d) return true
|
|
185
|
+
if (d.meta && 'current_page' in d.meta) return true
|
|
186
|
+
}
|
|
187
|
+
return false
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// -- Helper Data Accessors --
|
|
191
|
+
const serverMeta = computed(() => {
|
|
192
|
+
if (!isServerSide.value) return null
|
|
193
|
+
const d = internalData.value as any
|
|
194
|
+
// Handle standard Laravel Paginator or Resource Collection
|
|
195
|
+
const meta = d.meta || d
|
|
196
|
+
return {
|
|
197
|
+
current_page: meta.current_page ?? 1,
|
|
198
|
+
last_page: meta.last_page ?? 1,
|
|
199
|
+
from: meta.from ?? 0,
|
|
200
|
+
to: meta.to ?? 0,
|
|
201
|
+
total: meta.total ?? 0,
|
|
202
|
+
links: meta.links ?? []
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// -- Computed: Data Normalization --
|
|
207
|
+
const tableData = computed(() => {
|
|
208
|
+
if (isServerSide.value) {
|
|
209
|
+
const d = internalData.value as any
|
|
210
|
+
return d.data || []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Client Side Processing
|
|
214
|
+
let items = [...(internalData.value as any[])]
|
|
215
|
+
|
|
216
|
+
// 1. Filter
|
|
217
|
+
if (searchQuery.value) {
|
|
218
|
+
const lowerQuery = searchQuery.value.toLowerCase()
|
|
219
|
+
items = items.filter((item) =>
|
|
220
|
+
Object.values(item).some((val) =>
|
|
221
|
+
String(val).toLowerCase().includes(lowerQuery)
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 2. Sort
|
|
227
|
+
if (sortColumn.value) {
|
|
228
|
+
items.sort((a, b) => {
|
|
229
|
+
const valA = a[sortColumn.value]
|
|
230
|
+
const valB = b[sortColumn.value]
|
|
231
|
+
if (valA === valB) return 0
|
|
232
|
+
const comparison = valA > valB ? 1 : -1
|
|
233
|
+
return sortDirection.value === 'asc' ? comparison : -comparison
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 3. Paginate
|
|
238
|
+
const start = (currentPage.value - 1) * currentPerPage.value
|
|
239
|
+
const end = start + currentPerPage.value
|
|
240
|
+
return items.slice(start, end)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const totalPages = computed(() => {
|
|
244
|
+
if (isServerSide.value) {
|
|
245
|
+
return serverMeta.value?.last_page || 1
|
|
246
|
+
}
|
|
247
|
+
let filtered = (internalData.value as any[])
|
|
248
|
+
if (searchQuery.value) {
|
|
249
|
+
const lowerQuery = searchQuery.value.toLowerCase()
|
|
250
|
+
filtered = filtered.filter((item) =>
|
|
251
|
+
Object.values(item).some((val) =>
|
|
252
|
+
String(val).toLowerCase().includes(lowerQuery)
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
return Math.ceil(filtered.length / currentPerPage.value) || 1
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const paginationMeta = computed(() => {
|
|
260
|
+
if (isServerSide.value) {
|
|
261
|
+
return serverMeta.value || { from: 0, to: 0, total: 0 }
|
|
262
|
+
}
|
|
263
|
+
// Client side meta
|
|
264
|
+
let filtered = (internalData.value as any[])
|
|
265
|
+
if (searchQuery.value) {
|
|
266
|
+
const lowerQuery = searchQuery.value.toLowerCase()
|
|
267
|
+
filtered = filtered.filter((item) =>
|
|
268
|
+
Object.values(item).some((val) =>
|
|
269
|
+
String(val).toLowerCase().includes(lowerQuery)
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
const total = filtered.length;
|
|
274
|
+
const from = total === 0 ? 0 : (currentPage.value - 1) * currentPerPage.value + 1
|
|
275
|
+
const to = Math.min(from + currentPerPage.value - 1, total)
|
|
276
|
+
|
|
277
|
+
return { from, to, total }
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// -- Computed: Page Numbers for Pagination --
|
|
281
|
+
const pageNumbers = computed(() => {
|
|
282
|
+
const current = isServerSide.value ? (serverMeta.value?.current_page || 1) : currentPage.value
|
|
283
|
+
const total = totalPages.value
|
|
284
|
+
const delta = 2 // Number of pages to show on each side of current page
|
|
285
|
+
const pages: (number | string)[] = []
|
|
286
|
+
|
|
287
|
+
// Always show first page
|
|
288
|
+
pages.push(1)
|
|
289
|
+
|
|
290
|
+
// Calculate range around current page
|
|
291
|
+
const rangeStart = Math.max(2, current - delta)
|
|
292
|
+
const rangeEnd = Math.min(total - 1, current + delta)
|
|
293
|
+
|
|
294
|
+
// Add ellipsis after first page if needed
|
|
295
|
+
if (rangeStart > 2) {
|
|
296
|
+
pages.push('...')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Add pages in range
|
|
300
|
+
for (let i = rangeStart; i <= rangeEnd; i++) {
|
|
301
|
+
pages.push(i)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Add ellipsis before last page if needed
|
|
305
|
+
if (rangeEnd < total - 1) {
|
|
306
|
+
pages.push('...')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Always show last page if there's more than one page
|
|
310
|
+
if (total > 1) {
|
|
311
|
+
pages.push(total)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return pages
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// -- Methods --
|
|
318
|
+
|
|
319
|
+
async function fetchData(params: any = {}) {
|
|
320
|
+
if (props.fetchUrl) {
|
|
321
|
+
// Check cache first if enabled
|
|
322
|
+
const cacheKey = getCacheKey()
|
|
323
|
+
if (props.enableCache && responseCache.value.has(cacheKey)) {
|
|
324
|
+
// Use cached data
|
|
325
|
+
internalData.value = responseCache.value.get(cacheKey)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
isLoading.value = true
|
|
330
|
+
try {
|
|
331
|
+
// Construct Query Parameters
|
|
332
|
+
const url = new URL(props.fetchUrl, window.location.origin)
|
|
333
|
+
|
|
334
|
+
if (props.protocol === 'datatables') {
|
|
335
|
+
// DataTables format
|
|
336
|
+
const start = (currentPage.value - 1) * currentPerPage.value
|
|
337
|
+
url.searchParams.append('start', String(start))
|
|
338
|
+
url.searchParams.append('length', String(currentPerPage.value))
|
|
339
|
+
url.searchParams.append('draw', String(drawCounter.value))
|
|
340
|
+
|
|
341
|
+
if (searchQuery.value) {
|
|
342
|
+
url.searchParams.append('search[value]', searchQuery.value)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (sortColumn.value) {
|
|
346
|
+
// Find column index
|
|
347
|
+
const columnIndex = props.columns.findIndex(col => {
|
|
348
|
+
const sortKey = typeof col.sortable === 'string' ? col.sortable : col.key
|
|
349
|
+
return sortKey === sortColumn.value
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
if (columnIndex !== -1) {
|
|
353
|
+
url.searchParams.append('order[0][column]', String(columnIndex))
|
|
354
|
+
url.searchParams.append('order[0][dir]', sortDirection.value)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
// Laravel format (default)
|
|
359
|
+
url.searchParams.append('page', String(currentPage.value))
|
|
360
|
+
url.searchParams.append('per_page', String(currentPerPage.value))
|
|
361
|
+
|
|
362
|
+
if (searchQuery.value) {
|
|
363
|
+
url.searchParams.append('search', searchQuery.value)
|
|
364
|
+
}
|
|
365
|
+
if (sortColumn.value) {
|
|
366
|
+
url.searchParams.append('sort', sortColumn.value)
|
|
367
|
+
url.searchParams.append('order', sortDirection.value)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add custom query params from prop
|
|
372
|
+
if (props.queryParams) {
|
|
373
|
+
Object.keys(props.queryParams).forEach(key => {
|
|
374
|
+
const value = props.queryParams![key]
|
|
375
|
+
if (value !== null && value !== undefined) {
|
|
376
|
+
url.searchParams.append(key, String(value))
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Merge passed params (these override queryParams if there's a conflict)
|
|
382
|
+
Object.keys(params).forEach(key => {
|
|
383
|
+
url.searchParams.set(key, String(params[key]))
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
const response = await fetch(url.toString(), {
|
|
387
|
+
method: 'GET',
|
|
388
|
+
headers: {
|
|
389
|
+
'Accept': 'application/json',
|
|
390
|
+
'Content-Type': 'application/json',
|
|
391
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let data = await response.json()
|
|
400
|
+
|
|
401
|
+
// Transform DataTables response to internal format
|
|
402
|
+
if (props.protocol === 'datatables') {
|
|
403
|
+
// DataTables response: { draw, recordsTotal, recordsFiltered, data }
|
|
404
|
+
// Transform to Laravel format internally
|
|
405
|
+
const totalRecords = data.recordsFiltered || data.recordsTotal || 0
|
|
406
|
+
const totalPages = Math.ceil(totalRecords / currentPerPage.value)
|
|
407
|
+
|
|
408
|
+
data = {
|
|
409
|
+
data: data.data || [],
|
|
410
|
+
current_page: currentPage.value,
|
|
411
|
+
last_page: totalPages,
|
|
412
|
+
per_page: currentPerPage.value,
|
|
413
|
+
total: data.recordsFiltered || 0,
|
|
414
|
+
from: totalRecords > 0 ? ((currentPage.value - 1) * currentPerPage.value) + 1 : 0,
|
|
415
|
+
to: Math.min(currentPage.value * currentPerPage.value, totalRecords)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Increment draw counter for next request
|
|
419
|
+
drawCounter.value++
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
internalData.value = data
|
|
423
|
+
|
|
424
|
+
// Store in cache if enabled
|
|
425
|
+
if (props.enableCache) {
|
|
426
|
+
responseCache.value.set(cacheKey, data)
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error('Failed to fetch table data', error)
|
|
430
|
+
} finally {
|
|
431
|
+
isLoading.value = false
|
|
432
|
+
}
|
|
433
|
+
} else if (isServerSide.value) {
|
|
434
|
+
router.visit(window.location.pathname, {
|
|
435
|
+
data: {
|
|
436
|
+
page: params.page ?? currentPage.value,
|
|
437
|
+
per_page: currentPerPage.value,
|
|
438
|
+
search: params.search ?? searchQuery.value,
|
|
439
|
+
sort: params.sort ?? sortColumn.value,
|
|
440
|
+
order: params.order ?? sortDirection.value,
|
|
441
|
+
...(props.queryParams || {})
|
|
442
|
+
},
|
|
443
|
+
preserveState: true,
|
|
444
|
+
preserveScroll: true,
|
|
445
|
+
replace: true,
|
|
446
|
+
onStart: () => isLoading.value = true,
|
|
447
|
+
onFinish: () => isLoading.value = false
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ...
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
// ...
|
|
457
|
+
|
|
458
|
+
// -- Template for Pagination --
|
|
459
|
+
/*
|
|
460
|
+
<div class="flex items-center justify-between px-2">
|
|
461
|
+
<!-- Left side: Meta + Page Size -->
|
|
462
|
+
<div class="flex items-center gap-6">
|
|
463
|
+
<div class="text-sm text-muted-foreground">
|
|
464
|
+
Showing {{ paginationMeta.from }} to {{ paginationMeta.to }} of {{ paginationMeta.total }} results
|
|
465
|
+
</div>
|
|
466
|
+
<div class="flex items-center space-x-2">
|
|
467
|
+
<p class="text-sm font-medium hidden sm:block">Rows per page</p>
|
|
468
|
+
<Select
|
|
469
|
+
:model-value="String(currentPerPage)"
|
|
470
|
+
@update:model-value="handlePageSizeChange"
|
|
471
|
+
>
|
|
472
|
+
<SelectTrigger class="h-8 w-[70px]">
|
|
473
|
+
<SelectValue :placeholder="String(currentPerPage)" />
|
|
474
|
+
</SelectTrigger>
|
|
475
|
+
<SelectContent side="top">
|
|
476
|
+
<SelectItem
|
|
477
|
+
v-for="pageSize in pageSizes"
|
|
478
|
+
:key="pageSize"
|
|
479
|
+
:value="String(pageSize)"
|
|
480
|
+
>
|
|
481
|
+
{{ pageSize }}
|
|
482
|
+
</SelectItem>
|
|
483
|
+
</SelectContent>
|
|
484
|
+
</Select>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<!-- Right side: Nav Buttons -->
|
|
489
|
+
<div class="flex items-center space-x-2">
|
|
490
|
+
... buttons ...
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
*/
|
|
494
|
+
|
|
495
|
+
// -- Actions --
|
|
496
|
+
|
|
497
|
+
const debouncedSearch = useDebounceFn((value: string) => {
|
|
498
|
+
if (isServerSide.value) {
|
|
499
|
+
if (!props.fetchUrl) {
|
|
500
|
+
// Reset page for Inertia
|
|
501
|
+
// We do this manually here because fetchData logic is slightly different
|
|
502
|
+
fetchData({ search: value, page: 1 })
|
|
503
|
+
} else {
|
|
504
|
+
currentPage.value = 1
|
|
505
|
+
fetchData()
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
currentPage.value = 1
|
|
509
|
+
}
|
|
510
|
+
emit('update:search', value)
|
|
511
|
+
}, 300)
|
|
512
|
+
|
|
513
|
+
watch(searchQuery, (val) => {
|
|
514
|
+
debouncedSearch(val)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
function handleSort(col: any) {
|
|
518
|
+
// Determine the actual column to sort by
|
|
519
|
+
// If sortable is a string, use it; otherwise use the column key
|
|
520
|
+
const sortKey = typeof col.sortable === 'string' ? col.sortable : col.key
|
|
521
|
+
|
|
522
|
+
if (sortColumn.value === sortKey) {
|
|
523
|
+
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
|
524
|
+
} else {
|
|
525
|
+
sortColumn.value = sortKey
|
|
526
|
+
sortDirection.value = 'asc'
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (isServerSide.value) {
|
|
530
|
+
fetchData({ sort: sortColumn.value, order: sortDirection.value })
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
emit('update:sort', { column: sortColumn.value, direction: sortDirection.value })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function handlePageSizeChange(size: any) {
|
|
537
|
+
currentPerPage.value = Number(size)
|
|
538
|
+
currentPage.value = 1
|
|
539
|
+
fetchData()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handlePageChange(page: number) {
|
|
543
|
+
if (page < 1 || page > totalPages.value) return
|
|
544
|
+
|
|
545
|
+
currentPage.value = page
|
|
546
|
+
|
|
547
|
+
if (isServerSide.value) {
|
|
548
|
+
fetchData({ page: page })
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
emit('page-change', page)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function refresh() {
|
|
555
|
+
currentPage.value = 1
|
|
556
|
+
fetchData()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
onMounted(() => {
|
|
560
|
+
if (props.fetchUrl) {
|
|
561
|
+
fetchData()
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
defineExpose({
|
|
566
|
+
refresh,
|
|
567
|
+
fetchData, // exposing fetchData too just in case
|
|
568
|
+
clearCache // expose cache clearing method
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// -- Helper Styles --
|
|
572
|
+
function getCellClass(col: any, index: number, totalCols: number, rowIndex: number = -1) {
|
|
573
|
+
let classes = col.class || ''
|
|
574
|
+
|
|
575
|
+
// Base classes
|
|
576
|
+
// Removed whitespace-nowrap to allow wrapping
|
|
577
|
+
|
|
578
|
+
if (col.fixed) {
|
|
579
|
+
// Sticky logic
|
|
580
|
+
const isLast = index === totalCols - 1
|
|
581
|
+
|
|
582
|
+
let stickyClass = ''
|
|
583
|
+
if (isLast) {
|
|
584
|
+
// Right sticky: Stronger shadow to the left, and a left border
|
|
585
|
+
stickyClass = ' sticky right-0 z-10 shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-border/50'
|
|
586
|
+
} else {
|
|
587
|
+
// Left sticky: Stronger shadow to the right, and a right border
|
|
588
|
+
stickyClass = ' sticky left-0 z-10 shadow-[4px_0_8px_-2px_rgba(0,0,0,0.1)] border-r border-border/50'
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Determine background
|
|
592
|
+
// Sticky cells need opaque bg. We rely on the props.
|
|
593
|
+
let bgClass = 'bg-background' // Fallback
|
|
594
|
+
|
|
595
|
+
if (rowIndex !== -1) {
|
|
596
|
+
// Body Row
|
|
597
|
+
const isEven = rowIndex % 2 === 0
|
|
598
|
+
bgClass = isEven ? props.evenRowColor : props.oddRowColor
|
|
599
|
+
|
|
600
|
+
// Should also match hover
|
|
601
|
+
// If the row has a hover class (like hover:bg-muted), the sticky cell needs group-hover:bg-muted to match.
|
|
602
|
+
// We assume hoverColor is passed as 'hover:bg-...'
|
|
603
|
+
// We try to convert 'hover:bg-...' to 'group-hover:bg-...'
|
|
604
|
+
if (props.hoverColor) {
|
|
605
|
+
const hoverParts = props.hoverColor.split(':')
|
|
606
|
+
if (hoverParts.length > 1) {
|
|
607
|
+
// e.g. ['hover', 'bg-blue-100'] -> 'group-hover:bg-blue-100'
|
|
608
|
+
bgClass += ` group-hover:${hoverParts[1]}`
|
|
609
|
+
// Also handle things like 'hover:bg-muted/50'
|
|
610
|
+
if (hoverParts.length > 2) {
|
|
611
|
+
bgClass = bgClass + ':' + hoverParts.slice(2).join(':')
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
// Header Row
|
|
617
|
+
bgClass = 'bg-background'
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
classes += stickyClass + ' ' + bgClass
|
|
621
|
+
}
|
|
622
|
+
return classes
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getCellStyle(col: any) {
|
|
626
|
+
if (col.width) {
|
|
627
|
+
return { width: col.width, minWidth: col.width, maxWidth: col.width }
|
|
628
|
+
}
|
|
629
|
+
return {}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
</script>
|
|
633
|
+
|
|
634
|
+
<template>
|
|
635
|
+
<div class="space-y-4">
|
|
636
|
+
<!-- Toolbar -->
|
|
637
|
+
<div v-if="searchable" class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
638
|
+
<div v-if="searchable" class="relative w-full sm:max-w-sm flex items-center gap-2">
|
|
639
|
+
<!-- Page Size Select (Z-Index increased to sit above siblings) -->
|
|
640
|
+
<!-- Page Size Select (Native & Styled) -->
|
|
641
|
+
<div class="flex items-center gap-2 shrink-0 relative z-20">
|
|
642
|
+
<span class="text-sm text-muted-foreground whitespace-nowrap hidden sm:inline">Rows</span>
|
|
643
|
+
<div class="relative">
|
|
644
|
+
<select
|
|
645
|
+
:value="currentPerPage"
|
|
646
|
+
@change="(e: any) => handlePageSizeChange(e.target.value)"
|
|
647
|
+
class="h-10 w-[80px] appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
648
|
+
>
|
|
649
|
+
<option
|
|
650
|
+
v-for="pageSize in normalizedPageSizes"
|
|
651
|
+
:key="pageSize.value"
|
|
652
|
+
:value="pageSize.value"
|
|
653
|
+
>
|
|
654
|
+
{{ pageSize.label }}
|
|
655
|
+
</option>
|
|
656
|
+
</select>
|
|
657
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-2 top-3 h-4 w-4 opacity-50 pointer-events-none"><path d="m6 9 6 6 6-6"/></svg>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
|
|
661
|
+
<!-- Search Input -->
|
|
662
|
+
<div class="relative w-full z-10">
|
|
663
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
664
|
+
<input
|
|
665
|
+
v-model="searchQuery"
|
|
666
|
+
type="text"
|
|
667
|
+
placeholder="Search..."
|
|
668
|
+
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
669
|
+
/>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto sm:ml-auto justify-start sm:justify-end">
|
|
673
|
+
<slot name="actions" :rows="tableData" :columns="columns" />
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<!-- Table -->
|
|
678
|
+
<div class="rounded-md border bg-background overflow-x-auto relative">
|
|
679
|
+
<!-- We add min-w-full to Table to ensure it stretches -->
|
|
680
|
+
<Table class="min-w-full table-fixed">
|
|
681
|
+
<TableHeader>
|
|
682
|
+
<TableRow>
|
|
683
|
+
<TableHead
|
|
684
|
+
v-for="(col, idx) in columns"
|
|
685
|
+
:key="col.key"
|
|
686
|
+
:class="getCellClass(col, idx, columns.length)"
|
|
687
|
+
:style="getCellStyle(col)"
|
|
688
|
+
>
|
|
689
|
+
<div
|
|
690
|
+
v-if="col.sortable"
|
|
691
|
+
class="flex items-center space-x-2 cursor-pointer select-none hover:text-foreground"
|
|
692
|
+
@click="handleSort(col)"
|
|
693
|
+
>
|
|
694
|
+
<div>{{ col.label }}</div>
|
|
695
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 opacity-50 flex-shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
|
696
|
+
</div>
|
|
697
|
+
<div v-else>{{ col.label }}</div>
|
|
698
|
+
</TableHead>
|
|
699
|
+
</TableRow>
|
|
700
|
+
</TableHeader>
|
|
701
|
+
<TableBody>
|
|
702
|
+
<template v-if="isLoading">
|
|
703
|
+
<TableRow>
|
|
704
|
+
<TableCell :colspan="columns.length" class="h-24 text-center">
|
|
705
|
+
<div class="flex items-center justify-center">
|
|
706
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-6 w-6 animate-spin text-muted-foreground"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
|
707
|
+
</div>
|
|
708
|
+
</TableCell>
|
|
709
|
+
</TableRow>
|
|
710
|
+
</template>
|
|
711
|
+
<template v-else-if="tableData.length">
|
|
712
|
+
<TableRow
|
|
713
|
+
v-for="(row, idx) in tableData"
|
|
714
|
+
:key="idx"
|
|
715
|
+
class="group"
|
|
716
|
+
:class="[{ [evenRowColor]: Number(idx) % 2 === 0, [oddRowColor]: Number(idx) % 2 !== 0 }, hoverColor]"
|
|
717
|
+
>
|
|
718
|
+
<TableCell
|
|
719
|
+
v-for="(col, cIdx) in columns"
|
|
720
|
+
:key="col.key"
|
|
721
|
+
:class="getCellClass(col, cIdx, columns.length, Number(idx))"
|
|
722
|
+
:style="getCellStyle(col)"
|
|
723
|
+
>
|
|
724
|
+
<!-- Scoped Slot for custom cell rendering -->
|
|
725
|
+
<div>
|
|
726
|
+
<slot :name="`cell-${col.key}`" :row="row">
|
|
727
|
+
{{ row[col.key] }}
|
|
728
|
+
</slot>
|
|
729
|
+
</div>
|
|
730
|
+
</TableCell>
|
|
731
|
+
</TableRow>
|
|
732
|
+
</template>
|
|
733
|
+
<TableRow v-else>
|
|
734
|
+
<TableCell :colspan="columns.length" class="h-24 text-center">
|
|
735
|
+
No results.
|
|
736
|
+
</TableCell>
|
|
737
|
+
</TableRow>
|
|
738
|
+
</TableBody>
|
|
739
|
+
</Table>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<!-- Pagination -->
|
|
743
|
+
<div class="flex items-center justify-between px-2">
|
|
744
|
+
<div class="text-sm text-muted-foreground">
|
|
745
|
+
Showing {{ paginationMeta.from }} to {{ paginationMeta.to }} of {{ paginationMeta.total }} results
|
|
746
|
+
</div>
|
|
747
|
+
<div class="flex items-center space-x-1">
|
|
748
|
+
<!-- Previous Button -->
|
|
749
|
+
<button
|
|
750
|
+
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-3"
|
|
751
|
+
:disabled="(isServerSide ? serverMeta?.current_page === 1 : currentPage === 1)"
|
|
752
|
+
@click="handlePageChange(isServerSide ? (serverMeta?.current_page || 1) - 1 : currentPage - 1)"
|
|
753
|
+
>
|
|
754
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m15 18-6-6 6-6"/></svg>
|
|
755
|
+
<span class="ml-1 hidden sm:inline">Previous</span>
|
|
756
|
+
</button>
|
|
757
|
+
|
|
758
|
+
<!-- Page Number Buttons -->
|
|
759
|
+
<template v-for="(page, index) in pageNumbers" :key="index">
|
|
760
|
+
<!-- Ellipsis -->
|
|
761
|
+
<span
|
|
762
|
+
v-if="page === '...'"
|
|
763
|
+
class="inline-flex items-center justify-center h-9 px-3 text-sm text-muted-foreground"
|
|
764
|
+
>
|
|
765
|
+
...
|
|
766
|
+
</span>
|
|
767
|
+
|
|
768
|
+
<!-- Page Number Button -->
|
|
769
|
+
<button
|
|
770
|
+
v-else
|
|
771
|
+
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border h-9 min-w-[36px] px-3"
|
|
772
|
+
:class="[
|
|
773
|
+
(isServerSide ? serverMeta?.current_page === page : currentPage === page)
|
|
774
|
+
? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'
|
|
775
|
+
: 'border-input bg-background hover:bg-accent hover:text-accent-foreground'
|
|
776
|
+
]"
|
|
777
|
+
@click="handlePageChange(page as number)"
|
|
778
|
+
>
|
|
779
|
+
{{ page }}
|
|
780
|
+
</button>
|
|
781
|
+
</template>
|
|
782
|
+
|
|
783
|
+
<!-- Next Button -->
|
|
784
|
+
<button
|
|
785
|
+
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-3"
|
|
786
|
+
:disabled="(isServerSide ? serverMeta?.current_page === serverMeta?.last_page : currentPage === totalPages)"
|
|
787
|
+
@click="handlePageChange(isServerSide ? (serverMeta?.current_page || 1) + 1 : currentPage + 1)"
|
|
788
|
+
>
|
|
789
|
+
<span class="mr-1 hidden sm:inline">Next</span>
|
|
790
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m9 18 6-6-6-6"/></svg>
|
|
791
|
+
</button>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
</template>
|