@veristone/nuxt-v-app 0.1.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/README.md +42 -0
- package/app/app.vue +7 -0
- package/app/assets/css/v-app.css +313 -0
- package/app/components/V/A/Badge.vue +75 -0
- package/app/components/V/A/Btn/Add.vue +17 -0
- package/app/components/V/A/Btn/Back.vue +25 -0
- package/app/components/V/A/Btn/ConfirmDelete.vue +45 -0
- package/app/components/V/A/Btn/Edit.vue +35 -0
- package/app/components/V/A/Btn/Export.vue +28 -0
- package/app/components/V/A/Btn/Refresh.vue +21 -0
- package/app/components/V/A/Btn/Submit.vue +45 -0
- package/app/components/V/A/Btn/View.vue +23 -0
- package/app/components/V/A/Card.legacy.vue +291 -0
- package/app/components/V/A/Card.vue +108 -0
- package/app/components/V/A/CompanyMenu.vue +83 -0
- package/app/components/V/A/Data/KeyValue.vue +98 -0
- package/app/components/V/A/Data/StatusBadge.vue +44 -0
- package/app/components/V/A/DataField.vue +140 -0
- package/app/components/V/A/DataGrid.vue +43 -0
- package/app/components/V/A/DataTable.vue +144 -0
- package/app/components/V/A/EmptyState.vue +154 -0
- package/app/components/V/A/Fmt/Currency.vue +36 -0
- package/app/components/V/A/Fmt/DateTime.vue +34 -0
- package/app/components/V/A/Fmt/Percent.vue +47 -0
- package/app/components/V/A/LoadingState.vue +140 -0
- package/app/components/V/A/MetricCard.vue +129 -0
- package/app/components/V/A/Modal/Base.vue +195 -0
- package/app/components/V/A/Modal/Confirm.vue +92 -0
- package/app/components/V/A/Modal/Form.vue +105 -0
- package/app/components/V/A/Navigation.vue +110 -0
- package/app/components/V/A/QuickActions.vue +169 -0
- package/app/components/V/A/Slide.vue +109 -0
- package/app/components/V/A/Slideover.vue +259 -0
- package/app/components/V/A/State/Empty.vue +20 -0
- package/app/components/V/A/State/Error.vue +34 -0
- package/app/components/V/A/State/Loading.vue +33 -0
- package/app/components/V/A/StatsCard.vue +215 -0
- package/app/components/V/A/StatusBadge.vue +215 -0
- package/app/components/V/A/Table.vue +674 -0
- package/app/components/V/A/UserMenu.vue +127 -0
- package/app/components/V/A/WelcomeHeader.vue +96 -0
- package/app/components/V/Modal.vue +36 -0
- package/app/components/Va/Blocks/VaBlockGridCharts.vue +32 -0
- package/app/components/Va/Blocks/VaBlockGridKPI.vue +32 -0
- package/app/components/Va/Blocks/VaBlockGridTables.vue +23 -0
- package/app/components/Va/Blocks/VaBlockKpiGrid.vue +8 -0
- package/app/components/Va/Blocks/VaBlockSessionFilterBar.vue +8 -0
- package/app/components/Va/Cards/VaCardDonutChart.vue +59 -0
- package/app/components/Va/Cards/VaCardHeader.vue +10 -0
- package/app/components/Va/Cards/VaCardKpi.vue +17 -0
- package/app/components/Va/Cards/VaCardKpi2.vue +55 -0
- package/app/components/Va/Cards/VaCardLatestOrders.vue +82 -0
- package/app/components/Va/Cards/VaCardPopularProducts.vue +88 -0
- package/app/components/Va/Cards/VaCardRevenueBarChart.vue +49 -0
- package/app/components/Va/Cards/VaCardSubtitle.vue +5 -0
- package/app/components/Va/Cards/VaCardTitle.vue +5 -0
- package/app/components/Va/Cards/VaCardWithActiveUsers.vue +41 -0
- package/app/components/Va/Cards/VaCardWithChart.vue +135 -0
- package/app/components/Va/Cards/VaCardWithChartBlock.vue +26 -0
- package/app/components/Va/Cards/VaCardWithIndicator.vue +39 -0
- package/app/components/Va/Cards/VaCardWithProgressCircle.vue +34 -0
- package/app/components/Va/Cards/types.ts +11 -0
- package/app/components/Va/Charts/VaChartAppPerformanceBar.vue +118 -0
- package/app/components/Va/Charts/VaChartAppPerformanceBarChart.vue +118 -0
- package/app/components/Va/Charts/VaChartAreaMini.vue +127 -0
- package/app/components/Va/Charts/VaChartBarMini.vue +68 -0
- package/app/components/Va/Charts/VaChartCardinalMulti.vue +108 -0
- package/app/components/Va/Charts/VaChartColorBarChart.vue +78 -0
- package/app/components/Va/Charts/VaChartDonutHalf.vue +35 -0
- package/app/components/Va/Charts/VaChartDonutMini.vue +77 -0
- package/app/components/Va/Charts/VaChartExpensesBar.vue +58 -0
- package/app/components/Va/Charts/VaChartFinanceSummary.vue +96 -0
- package/app/components/Va/Charts/VaChartGoogleSearchConsole.vue +90 -0
- package/app/components/Va/Charts/VaChartIncomeBar.vue +82 -0
- package/app/components/Va/Charts/VaChartLegend.vue +25 -0
- package/app/components/Va/Charts/VaChartLineMini.vue +205 -0
- package/app/components/Va/Charts/VaChartRealtimeTraffic.vue +182 -0
- package/app/components/Va/Charts/VaChartRevenue.vue +43 -0
- package/app/components/Va/Charts/VaChartRevenueLine.vue +42 -0
- package/app/components/Va/Charts/VaChartRevenuevsCost.vue +84 -0
- package/app/components/Va/Charts/VaChartSearchIntent.vue +179 -0
- package/app/components/Va/Charts/VaChartSpendingTrend.vue +127 -0
- package/app/components/Va/Charts/VaChartStackedHorizontal.vue +64 -0
- package/app/components/Va/Charts/VaChartStepMinimal.vue +109 -0
- package/app/components/Va/Charts/VaChartStockComparisonLine.vue +86 -0
- package/app/components/Va/Charts/VaChartStocksPortfolioLine.vue +161 -0
- package/app/components/Va/Charts/VaChartStocksSectorLine.vue +223 -0
- package/app/components/Va/Charts/VaChartTasksCategories.vue +96 -0
- package/app/components/Va/Charts/VaChartTasksProgress.vue +130 -0
- package/app/components/Va/Charts/VaChartTrafficOverview.vue +112 -0
- package/app/components/Va/Charts/VaChartWebPerformanceLineChart.vue +114 -0
- package/app/components/Va/Charts/VaChartWinLostBar.vue +110 -0
- package/app/components/Va/Charts/VaChartWinLostDonut.vue +107 -0
- package/app/components/Va/Charts/VaChartWinLostLine.vue +111 -0
- package/app/components/Va/Charts/types.ts +10 -0
- package/app/components/Va/Dashboard/Navigation/types.ts +8 -0
- package/app/components/Va/Dashboard/VaDashboardKPICard.vue +31 -0
- package/app/components/Va/Dashboard/VaDashboardNavigation.vue +50 -0
- package/app/components/Va/Dashboard/VaDashboardPricePlan.vue +102 -0
- package/app/components/Va/Dashboard/VaDashboardUsageChart.vue +84 -0
- package/app/components/Va/Dashboard/VaDashboardUsageRequestChart.vue +46 -0
- package/app/components/Va/Layout/NotificationsSlideover.vue +169 -0
- package/app/components/Va/Layout/SideNav/types.ts +5 -0
- package/app/components/Va/Layout/SideNav.vue +108 -0
- package/app/components/Va/Layout/TeamsMenu.vue +57 -0
- package/app/components/Va/Layout/UserMenu.vue +57 -0
- package/app/composables/useDashboard.ts +25 -0
- package/app/composables/useVAAnimation.ts +324 -0
- package/app/composables/useVAUtils.ts +118 -0
- package/app/composables/useVCrud.ts +647 -0
- package/app/composables/useVFetch.ts +46 -0
- package/app/composables/useVFileUpload.ts +45 -0
- package/app/composables/useVToast.ts +73 -0
- package/app/composables/useXATableColumns.ts +456 -0
- package/app/data/BillingStats.ts +65 -0
- package/app/data/SearchData.ts +58 -0
- package/app/data/TasksData.ts +101 -0
- package/app/data/dashboardData.ts +113 -0
- package/app/layouts/default.vue +171 -0
- package/app/layouts/legacy.vue +61 -0
- package/app/pages/playground/base.vue +498 -0
- package/app/pages/playground/blocks.vue +108 -0
- package/app/pages/playground/buttons.vue +237 -0
- package/app/pages/playground/cards.vue +326 -0
- package/app/pages/playground/charts.vue +338 -0
- package/app/pages/playground/dashboard.vue +315 -0
- package/app/pages/playground/formatters.vue +329 -0
- package/app/pages/playground/index.vue +109 -0
- package/app/pages/playground/layout.vue +159 -0
- package/app/pages/playground/modals.vue +606 -0
- package/app/pages/playground/states.vue +282 -0
- package/app/pages/playground/tables.vue +618 -0
- package/app/pages/test-layout.vue +10 -0
- package/nuxt.config.ts +12 -0
- package/package.json +71 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { refDebounced } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VATable - Full-Featured Data Table
|
|
6
|
+
* HubSpot-inspired design with search, sort, filter, export, selection, and pagination
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TableColumn {
|
|
10
|
+
id?: string
|
|
11
|
+
accessorKey?: string
|
|
12
|
+
header: string
|
|
13
|
+
enableSorting?: boolean
|
|
14
|
+
enableHiding?: boolean
|
|
15
|
+
cell?: (info: { row: { original: any }, getValue: () => any }) => any
|
|
16
|
+
meta?: {
|
|
17
|
+
class?: string
|
|
18
|
+
headerClass?: string
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SortState {
|
|
23
|
+
column: string
|
|
24
|
+
direction: 'asc' | 'desc' | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<{
|
|
28
|
+
// Data
|
|
29
|
+
data: any[]
|
|
30
|
+
columns: TableColumn[]
|
|
31
|
+
rowKey?: string
|
|
32
|
+
|
|
33
|
+
// Features
|
|
34
|
+
searchable?: boolean
|
|
35
|
+
searchPlaceholder?: string
|
|
36
|
+
sortable?: boolean
|
|
37
|
+
selectable?: boolean | 'single' | 'multi'
|
|
38
|
+
showColumnToggle?: boolean
|
|
39
|
+
exportable?: boolean
|
|
40
|
+
exportFormats?: ('csv' | 'json')[]
|
|
41
|
+
|
|
42
|
+
// Pagination
|
|
43
|
+
paginated?: boolean
|
|
44
|
+
pageSize?: number
|
|
45
|
+
pageSizes?: number[]
|
|
46
|
+
showPageSize?: boolean
|
|
47
|
+
total?: number
|
|
48
|
+
|
|
49
|
+
// UI
|
|
50
|
+
card?: boolean
|
|
51
|
+
name?: string
|
|
52
|
+
stickyHeader?: boolean
|
|
53
|
+
striped?: boolean
|
|
54
|
+
compact?: boolean
|
|
55
|
+
|
|
56
|
+
// Actions
|
|
57
|
+
showView?: boolean
|
|
58
|
+
showEdit?: boolean
|
|
59
|
+
showDelete?: boolean
|
|
60
|
+
|
|
61
|
+
// Empty state
|
|
62
|
+
emptyTitle?: string
|
|
63
|
+
emptyDescription?: string
|
|
64
|
+
emptyIcon?: string
|
|
65
|
+
|
|
66
|
+
// Loading
|
|
67
|
+
loading?: boolean
|
|
68
|
+
|
|
69
|
+
// Sort (controlled)
|
|
70
|
+
sort?: SortState | null
|
|
71
|
+
}>(), {
|
|
72
|
+
rowKey: 'id',
|
|
73
|
+
searchable: false,
|
|
74
|
+
searchPlaceholder: 'Search...',
|
|
75
|
+
sortable: true,
|
|
76
|
+
selectable: false,
|
|
77
|
+
showColumnToggle: false,
|
|
78
|
+
exportable: false,
|
|
79
|
+
exportFormats: () => ['csv', 'json'],
|
|
80
|
+
paginated: true,
|
|
81
|
+
pageSize: 10,
|
|
82
|
+
pageSizes: () => [10, 20, 50, 100],
|
|
83
|
+
showPageSize: true,
|
|
84
|
+
total: 0,
|
|
85
|
+
card: false,
|
|
86
|
+
name: '',
|
|
87
|
+
stickyHeader: false,
|
|
88
|
+
striped: false,
|
|
89
|
+
compact: false,
|
|
90
|
+
showView: false,
|
|
91
|
+
showEdit: false,
|
|
92
|
+
showDelete: false,
|
|
93
|
+
emptyTitle: 'No records found',
|
|
94
|
+
emptyDescription: 'Try adjusting your search or filters.',
|
|
95
|
+
emptyIcon: 'i-lucide-inbox',
|
|
96
|
+
loading: false,
|
|
97
|
+
sort: null
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const emit = defineEmits<{
|
|
101
|
+
search: [value: string]
|
|
102
|
+
'update:sort': [sort: SortState | null]
|
|
103
|
+
select: [rows: any[]]
|
|
104
|
+
'row-click': [row: any]
|
|
105
|
+
page: [page: number]
|
|
106
|
+
'page-size': [size: number]
|
|
107
|
+
'update:pagination': [{ pageSize: number; pageIndex: number }]
|
|
108
|
+
view: [row: any]
|
|
109
|
+
edit: [row: any]
|
|
110
|
+
delete: [row: any]
|
|
111
|
+
export: [format: 'csv' | 'json', data: any[]]
|
|
112
|
+
}>()
|
|
113
|
+
|
|
114
|
+
// Internal State
|
|
115
|
+
const searchQuery = ref('')
|
|
116
|
+
const debouncedSearch = refDebounced(searchQuery, 300)
|
|
117
|
+
const currentPage = ref(1)
|
|
118
|
+
const internalPageSize = ref(props.pageSize)
|
|
119
|
+
const selectedRows = ref<any[]>([])
|
|
120
|
+
const hiddenColumns = ref<Set<string>>(new Set())
|
|
121
|
+
const internalSort = ref<SortState | null>(props.sort)
|
|
122
|
+
|
|
123
|
+
// Column toggle dropdown state
|
|
124
|
+
const columnToggleOpen = ref(false)
|
|
125
|
+
const exportMenuOpen = ref(false)
|
|
126
|
+
|
|
127
|
+
// Watch for external search changes
|
|
128
|
+
watch(debouncedSearch, (val) => emit('search', val))
|
|
129
|
+
|
|
130
|
+
// Computed: Get column key
|
|
131
|
+
const getColumnKey = (col: TableColumn): string => col.id || col.accessorKey || col.header
|
|
132
|
+
|
|
133
|
+
// Computed: Visible columns (excluding hidden)
|
|
134
|
+
const visibleColumns = computed(() => {
|
|
135
|
+
const cols = props.columns.filter(col => !hiddenColumns.value.has(getColumnKey(col)))
|
|
136
|
+
|
|
137
|
+
// Add actions column if any action is enabled
|
|
138
|
+
if (props.showView || props.showEdit || props.showDelete) {
|
|
139
|
+
cols.push({
|
|
140
|
+
id: 'actions',
|
|
141
|
+
header: '',
|
|
142
|
+
enableSorting: false,
|
|
143
|
+
enableHiding: false
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return cols
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Computed: Filter data by search
|
|
151
|
+
const filteredData = computed(() => {
|
|
152
|
+
if (!debouncedSearch.value) return props.data
|
|
153
|
+
|
|
154
|
+
const query = debouncedSearch.value.toLowerCase()
|
|
155
|
+
return props.data.filter(row => {
|
|
156
|
+
return props.columns.some(col => {
|
|
157
|
+
const key = col.accessorKey || col.id
|
|
158
|
+
if (!key) return false
|
|
159
|
+
const value = row[key]
|
|
160
|
+
if (value == null) return false
|
|
161
|
+
return String(value).toLowerCase().includes(query)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Computed: Sort data
|
|
167
|
+
const sortedData = computed(() => {
|
|
168
|
+
const sortState = internalSort.value || props.sort
|
|
169
|
+
if (!sortState || !sortState.direction) return filteredData.value
|
|
170
|
+
|
|
171
|
+
const { column, direction } = sortState
|
|
172
|
+
return [...filteredData.value].sort((a, b) => {
|
|
173
|
+
const aVal = a[column]
|
|
174
|
+
const bVal = b[column]
|
|
175
|
+
|
|
176
|
+
if (aVal == null && bVal == null) return 0
|
|
177
|
+
if (aVal == null) return 1
|
|
178
|
+
if (bVal == null) return -1
|
|
179
|
+
|
|
180
|
+
let comparison = 0
|
|
181
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
182
|
+
comparison = aVal - bVal
|
|
183
|
+
} else {
|
|
184
|
+
comparison = String(aVal).localeCompare(String(bVal))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return direction === 'desc' ? -comparison : comparison
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Computed: Total rows
|
|
192
|
+
const totalRows = computed(() => props.total || sortedData.value.length)
|
|
193
|
+
|
|
194
|
+
// Computed: Paginated data
|
|
195
|
+
const paginatedData = computed(() => {
|
|
196
|
+
if (!props.paginated) return sortedData.value
|
|
197
|
+
|
|
198
|
+
const start = (currentPage.value - 1) * internalPageSize.value
|
|
199
|
+
const end = start + internalPageSize.value
|
|
200
|
+
return sortedData.value.slice(start, end)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// Computed: Page count
|
|
204
|
+
const pageCount = computed(() => Math.ceil(totalRows.value / internalPageSize.value))
|
|
205
|
+
|
|
206
|
+
// Computed: Showing range text
|
|
207
|
+
const showingText = computed(() => {
|
|
208
|
+
const start = (currentPage.value - 1) * internalPageSize.value + 1
|
|
209
|
+
const end = Math.min(currentPage.value * internalPageSize.value, totalRows.value)
|
|
210
|
+
return `Showing ${start} to ${end} of ${totalRows.value} results`
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Methods
|
|
214
|
+
const handleSort = (column: TableColumn) => {
|
|
215
|
+
if (!props.sortable || column.enableSorting === false) return
|
|
216
|
+
|
|
217
|
+
const key = getColumnKey(column)
|
|
218
|
+
const currentSort = internalSort.value || props.sort
|
|
219
|
+
|
|
220
|
+
let newDirection: 'asc' | 'desc' | null = 'asc'
|
|
221
|
+
|
|
222
|
+
if (currentSort?.column === key) {
|
|
223
|
+
if (currentSort.direction === 'asc') newDirection = 'desc'
|
|
224
|
+
else if (currentSort.direction === 'desc') newDirection = null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const newSort = newDirection ? { column: key, direction: newDirection } : null
|
|
228
|
+
internalSort.value = newSort
|
|
229
|
+
emit('update:sort', newSort)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const getSortIcon = (column: TableColumn): string => {
|
|
233
|
+
const key = getColumnKey(column)
|
|
234
|
+
const currentSort = internalSort.value || props.sort
|
|
235
|
+
|
|
236
|
+
if (currentSort?.column !== key) return 'i-lucide-chevrons-up-down'
|
|
237
|
+
if (currentSort.direction === 'asc') return 'i-lucide-chevron-up'
|
|
238
|
+
if (currentSort.direction === 'desc') return 'i-lucide-chevron-down'
|
|
239
|
+
return 'i-lucide-chevrons-up-down'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleRowClick = (row: any) => {
|
|
243
|
+
emit('row-click', row)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const toggleRowSelection = (row: any) => {
|
|
247
|
+
const key = row[props.rowKey]
|
|
248
|
+
const index = selectedRows.value.findIndex(r => r[props.rowKey] === key)
|
|
249
|
+
|
|
250
|
+
if (props.selectable === 'single') {
|
|
251
|
+
selectedRows.value = index >= 0 ? [] : [row]
|
|
252
|
+
} else {
|
|
253
|
+
if (index >= 0) {
|
|
254
|
+
selectedRows.value.splice(index, 1)
|
|
255
|
+
} else {
|
|
256
|
+
selectedRows.value.push(row)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
emit('select', selectedRows.value)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const isRowSelected = (row: any): boolean => {
|
|
264
|
+
return selectedRows.value.some(r => r[props.rowKey] === row[props.rowKey])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const toggleSelectAll = () => {
|
|
268
|
+
if (selectedRows.value.length === paginatedData.value.length) {
|
|
269
|
+
selectedRows.value = []
|
|
270
|
+
} else {
|
|
271
|
+
selectedRows.value = [...paginatedData.value]
|
|
272
|
+
}
|
|
273
|
+
emit('select', selectedRows.value)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const isAllSelected = computed(() => {
|
|
277
|
+
return paginatedData.value.length > 0 && selectedRows.value.length === paginatedData.value.length
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const isSomeSelected = computed(() => {
|
|
281
|
+
return selectedRows.value.length > 0 && selectedRows.value.length < paginatedData.value.length
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const toggleColumn = (column: TableColumn) => {
|
|
285
|
+
const key = getColumnKey(column)
|
|
286
|
+
if (hiddenColumns.value.has(key)) {
|
|
287
|
+
hiddenColumns.value.delete(key)
|
|
288
|
+
} else {
|
|
289
|
+
hiddenColumns.value.add(key)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const handlePageChange = (page: number) => {
|
|
294
|
+
currentPage.value = page
|
|
295
|
+
emit('page', page)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const handlePageSizeChange = (size: number) => {
|
|
299
|
+
internalPageSize.value = size
|
|
300
|
+
currentPage.value = 1
|
|
301
|
+
emit('page-size', size)
|
|
302
|
+
emit('update:pagination', { pageSize: size, pageIndex: 0 })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const getCellValue = (row: any, column: TableColumn): any => {
|
|
306
|
+
const key = column.accessorKey || column.id
|
|
307
|
+
if (!key) return ''
|
|
308
|
+
return row[key]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Export functions
|
|
312
|
+
const exportToCSV = () => {
|
|
313
|
+
const headers = props.columns
|
|
314
|
+
.filter(col => col.accessorKey || col.id)
|
|
315
|
+
.map(col => col.header)
|
|
316
|
+
|
|
317
|
+
const rows = sortedData.value.map(row =>
|
|
318
|
+
props.columns
|
|
319
|
+
.filter(col => col.accessorKey || col.id)
|
|
320
|
+
.map(col => {
|
|
321
|
+
const val = getCellValue(row, col)
|
|
322
|
+
// Escape quotes and wrap in quotes if contains comma
|
|
323
|
+
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
|
324
|
+
return `"${val.replace(/"/g, '""')}"`
|
|
325
|
+
}
|
|
326
|
+
return val ?? ''
|
|
327
|
+
})
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
|
|
331
|
+
downloadFile(csv, `${props.name || 'export'}.csv`, 'text/csv')
|
|
332
|
+
emit('export', 'csv', sortedData.value)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const exportToJSON = () => {
|
|
336
|
+
const json = JSON.stringify(sortedData.value, null, 2)
|
|
337
|
+
downloadFile(json, `${props.name || 'export'}.json`, 'application/json')
|
|
338
|
+
emit('export', 'json', sortedData.value)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const downloadFile = (content: string, filename: string, mimeType: string) => {
|
|
342
|
+
const blob = new Blob([content], { type: mimeType })
|
|
343
|
+
const url = URL.createObjectURL(blob)
|
|
344
|
+
const link = document.createElement('a')
|
|
345
|
+
link.href = url
|
|
346
|
+
link.download = filename
|
|
347
|
+
document.body.appendChild(link)
|
|
348
|
+
link.click()
|
|
349
|
+
document.body.removeChild(link)
|
|
350
|
+
URL.revokeObjectURL(url)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Slots
|
|
354
|
+
const slots = useSlots()
|
|
355
|
+
</script>
|
|
356
|
+
|
|
357
|
+
<template>
|
|
358
|
+
<div
|
|
359
|
+
:class="[
|
|
360
|
+
card ? 'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-[var(--va-card-shadow)] overflow-hidden' : ''
|
|
361
|
+
]"
|
|
362
|
+
>
|
|
363
|
+
<!-- Header/Toolbar -->
|
|
364
|
+
<div
|
|
365
|
+
v-if="name || searchable || $slots.toolbar || showColumnToggle || exportable"
|
|
366
|
+
class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex flex-wrap items-center justify-between gap-4"
|
|
367
|
+
>
|
|
368
|
+
<div class="flex items-center gap-3">
|
|
369
|
+
<h3 v-if="name" class="font-bold text-gray-900 dark:text-white text-base">{{ name }}</h3>
|
|
370
|
+
<span
|
|
371
|
+
v-if="selectable && selectedRows.length > 0"
|
|
372
|
+
class="text-sm text-gray-500 dark:text-gray-400"
|
|
373
|
+
>
|
|
374
|
+
{{ selectedRows.length }} selected
|
|
375
|
+
</span>
|
|
376
|
+
<slot name="toolbar" />
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div class="flex items-center gap-2">
|
|
380
|
+
<!-- Search -->
|
|
381
|
+
<UInput
|
|
382
|
+
v-if="searchable"
|
|
383
|
+
v-model="searchQuery"
|
|
384
|
+
:placeholder="searchPlaceholder"
|
|
385
|
+
icon="i-lucide-search"
|
|
386
|
+
size="sm"
|
|
387
|
+
class="w-64"
|
|
388
|
+
>
|
|
389
|
+
<template v-if="searchQuery" #trailing>
|
|
390
|
+
<UButton
|
|
391
|
+
color="neutral"
|
|
392
|
+
variant="link"
|
|
393
|
+
icon="i-lucide-x"
|
|
394
|
+
size="xs"
|
|
395
|
+
:padded="false"
|
|
396
|
+
@click="searchQuery = ''"
|
|
397
|
+
/>
|
|
398
|
+
</template>
|
|
399
|
+
</UInput>
|
|
400
|
+
|
|
401
|
+
<!-- Column Toggle -->
|
|
402
|
+
<UDropdownMenu v-if="showColumnToggle">
|
|
403
|
+
<UButton
|
|
404
|
+
color="neutral"
|
|
405
|
+
variant="outline"
|
|
406
|
+
icon="i-lucide-columns-3"
|
|
407
|
+
size="sm"
|
|
408
|
+
/>
|
|
409
|
+
<template #content>
|
|
410
|
+
<div class="p-2 min-w-[180px]">
|
|
411
|
+
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 px-2">Columns</p>
|
|
412
|
+
<div
|
|
413
|
+
v-for="col in columns"
|
|
414
|
+
:key="getColumnKey(col)"
|
|
415
|
+
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
|
416
|
+
@click="toggleColumn(col)"
|
|
417
|
+
>
|
|
418
|
+
<UCheckbox
|
|
419
|
+
:model-value="!hiddenColumns.has(getColumnKey(col))"
|
|
420
|
+
size="sm"
|
|
421
|
+
/>
|
|
422
|
+
<span class="text-sm text-gray-700 dark:text-gray-300">{{ col.header }}</span>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</template>
|
|
426
|
+
</UDropdownMenu>
|
|
427
|
+
|
|
428
|
+
<!-- Export -->
|
|
429
|
+
<UDropdownMenu v-if="exportable">
|
|
430
|
+
<UButton
|
|
431
|
+
color="neutral"
|
|
432
|
+
variant="outline"
|
|
433
|
+
icon="i-lucide-download"
|
|
434
|
+
size="sm"
|
|
435
|
+
/>
|
|
436
|
+
<template #content>
|
|
437
|
+
<div class="py-1">
|
|
438
|
+
<button
|
|
439
|
+
v-if="exportFormats.includes('csv')"
|
|
440
|
+
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"
|
|
441
|
+
@click="exportToCSV"
|
|
442
|
+
>
|
|
443
|
+
<UIcon name="i-lucide-file-spreadsheet" class="w-4 h-4" />
|
|
444
|
+
Export as CSV
|
|
445
|
+
</button>
|
|
446
|
+
<button
|
|
447
|
+
v-if="exportFormats.includes('json')"
|
|
448
|
+
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"
|
|
449
|
+
@click="exportToJSON"
|
|
450
|
+
>
|
|
451
|
+
<UIcon name="i-lucide-braces" class="w-4 h-4" />
|
|
452
|
+
Export as JSON
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
</template>
|
|
456
|
+
</UDropdownMenu>
|
|
457
|
+
|
|
458
|
+
<slot name="actions" />
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<!-- Table -->
|
|
463
|
+
<div class="overflow-x-auto">
|
|
464
|
+
<table class="w-full">
|
|
465
|
+
<!-- Header -->
|
|
466
|
+
<thead
|
|
467
|
+
:class="[
|
|
468
|
+
'bg-gray-50/80 dark:bg-gray-800/50',
|
|
469
|
+
stickyHeader ? 'sticky top-0 z-10' : ''
|
|
470
|
+
]"
|
|
471
|
+
>
|
|
472
|
+
<tr>
|
|
473
|
+
<!-- Select All Checkbox -->
|
|
474
|
+
<th
|
|
475
|
+
v-if="selectable"
|
|
476
|
+
class="w-12 px-4 py-3"
|
|
477
|
+
>
|
|
478
|
+
<UCheckbox
|
|
479
|
+
v-if="selectable === 'multi' || selectable === true"
|
|
480
|
+
:model-value="isAllSelected"
|
|
481
|
+
:indeterminate="isSomeSelected"
|
|
482
|
+
@update:model-value="toggleSelectAll"
|
|
483
|
+
/>
|
|
484
|
+
</th>
|
|
485
|
+
|
|
486
|
+
<!-- Column Headers -->
|
|
487
|
+
<th
|
|
488
|
+
v-for="col in visibleColumns"
|
|
489
|
+
:key="getColumnKey(col)"
|
|
490
|
+
:class="[
|
|
491
|
+
'px-4 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider',
|
|
492
|
+
compact ? 'py-2.5' : 'py-3.5',
|
|
493
|
+
col.meta?.headerClass || '',
|
|
494
|
+
sortable && col.enableSorting !== false ? 'cursor-pointer select-none hover:text-gray-900 dark:hover:text-white transition-colors' : '',
|
|
495
|
+
col.id === 'actions' ? 'w-24 text-right' : ''
|
|
496
|
+
]"
|
|
497
|
+
@click="handleSort(col)"
|
|
498
|
+
>
|
|
499
|
+
<div class="flex items-center gap-1.5" :class="col.id === 'actions' ? 'justify-end' : ''">
|
|
500
|
+
<span>{{ col.header }}</span>
|
|
501
|
+
<UIcon
|
|
502
|
+
v-if="sortable && col.enableSorting !== false && col.id !== 'actions'"
|
|
503
|
+
:name="getSortIcon(col)"
|
|
504
|
+
class="w-4 h-4 text-gray-400"
|
|
505
|
+
/>
|
|
506
|
+
</div>
|
|
507
|
+
</th>
|
|
508
|
+
</tr>
|
|
509
|
+
</thead>
|
|
510
|
+
|
|
511
|
+
<!-- Body -->
|
|
512
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
513
|
+
<!-- Loading State -->
|
|
514
|
+
<template v-if="loading">
|
|
515
|
+
<tr v-for="i in internalPageSize" :key="`skeleton-${i}`">
|
|
516
|
+
<td v-if="selectable" class="px-4 py-4">
|
|
517
|
+
<div class="va-skeleton h-4 w-4 rounded" />
|
|
518
|
+
</td>
|
|
519
|
+
<td
|
|
520
|
+
v-for="col in visibleColumns"
|
|
521
|
+
:key="`skeleton-${i}-${getColumnKey(col)}`"
|
|
522
|
+
class="px-4 py-4"
|
|
523
|
+
>
|
|
524
|
+
<div class="va-skeleton h-4 w-3/4 rounded" />
|
|
525
|
+
</td>
|
|
526
|
+
</tr>
|
|
527
|
+
</template>
|
|
528
|
+
|
|
529
|
+
<!-- Empty State -->
|
|
530
|
+
<template v-else-if="paginatedData.length === 0">
|
|
531
|
+
<tr>
|
|
532
|
+
<td
|
|
533
|
+
:colspan="visibleColumns.length + (selectable ? 1 : 0)"
|
|
534
|
+
class="px-4 py-16 text-center"
|
|
535
|
+
>
|
|
536
|
+
<div class="flex flex-col items-center justify-center">
|
|
537
|
+
<div class="p-4 rounded-full bg-gray-50 dark:bg-gray-800 mb-4">
|
|
538
|
+
<UIcon :name="emptyIcon" class="w-10 h-10 text-gray-400" />
|
|
539
|
+
</div>
|
|
540
|
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">{{ emptyTitle }}</h3>
|
|
541
|
+
<p class="text-sm text-gray-500 max-w-sm">{{ emptyDescription }}</p>
|
|
542
|
+
</div>
|
|
543
|
+
</td>
|
|
544
|
+
</tr>
|
|
545
|
+
</template>
|
|
546
|
+
|
|
547
|
+
<!-- Data Rows -->
|
|
548
|
+
<template v-else>
|
|
549
|
+
<tr
|
|
550
|
+
v-for="row in paginatedData"
|
|
551
|
+
:key="row[rowKey]"
|
|
552
|
+
class="group va-table-row-hover"
|
|
553
|
+
:class="[
|
|
554
|
+
striped ? 'odd:bg-gray-50/50 dark:odd:bg-gray-800/30' : '',
|
|
555
|
+
isRowSelected(row) ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''
|
|
556
|
+
]"
|
|
557
|
+
@click="handleRowClick(row)"
|
|
558
|
+
>
|
|
559
|
+
<!-- Selection Checkbox -->
|
|
560
|
+
<td
|
|
561
|
+
v-if="selectable"
|
|
562
|
+
class="px-4"
|
|
563
|
+
:class="compact ? 'py-2.5' : 'py-4'"
|
|
564
|
+
@click.stop
|
|
565
|
+
>
|
|
566
|
+
<UCheckbox
|
|
567
|
+
:model-value="isRowSelected(row)"
|
|
568
|
+
@update:model-value="toggleRowSelection(row)"
|
|
569
|
+
/>
|
|
570
|
+
</td>
|
|
571
|
+
|
|
572
|
+
<!-- Data Cells -->
|
|
573
|
+
<td
|
|
574
|
+
v-for="col in visibleColumns"
|
|
575
|
+
:key="`${row[rowKey]}-${getColumnKey(col)}`"
|
|
576
|
+
:class="[
|
|
577
|
+
'px-4 text-sm text-gray-600 dark:text-gray-300',
|
|
578
|
+
compact ? 'py-2.5' : 'py-4',
|
|
579
|
+
col.meta?.class || '',
|
|
580
|
+
col.id === 'actions' ? 'text-right' : ''
|
|
581
|
+
]"
|
|
582
|
+
>
|
|
583
|
+
<!-- Actions Column -->
|
|
584
|
+
<template v-if="col.id === 'actions'">
|
|
585
|
+
<div class="va-row-actions flex items-center justify-end gap-1">
|
|
586
|
+
<UButton
|
|
587
|
+
v-if="showView"
|
|
588
|
+
icon="i-lucide-eye"
|
|
589
|
+
color="neutral"
|
|
590
|
+
variant="ghost"
|
|
591
|
+
size="xs"
|
|
592
|
+
@click.stop="emit('view', row)"
|
|
593
|
+
/>
|
|
594
|
+
<UButton
|
|
595
|
+
v-if="showEdit"
|
|
596
|
+
icon="i-lucide-pencil"
|
|
597
|
+
color="neutral"
|
|
598
|
+
variant="ghost"
|
|
599
|
+
size="xs"
|
|
600
|
+
@click.stop="emit('edit', row)"
|
|
601
|
+
/>
|
|
602
|
+
<UButton
|
|
603
|
+
v-if="showDelete"
|
|
604
|
+
icon="i-lucide-trash-2"
|
|
605
|
+
color="error"
|
|
606
|
+
variant="ghost"
|
|
607
|
+
size="xs"
|
|
608
|
+
@click.stop="emit('delete', row)"
|
|
609
|
+
/>
|
|
610
|
+
</div>
|
|
611
|
+
</template>
|
|
612
|
+
|
|
613
|
+
<!-- Custom Cell Slot -->
|
|
614
|
+
<template v-else-if="slots[`${getColumnKey(col)}-data`]">
|
|
615
|
+
<slot :name="`${getColumnKey(col)}-data`" :row="row" :value="getCellValue(row, col)" />
|
|
616
|
+
</template>
|
|
617
|
+
|
|
618
|
+
<!-- Custom Cell Renderer -->
|
|
619
|
+
<template v-else-if="col.cell">
|
|
620
|
+
<component
|
|
621
|
+
:is="() => col.cell!({ row: { original: row }, getValue: () => getCellValue(row, col) })"
|
|
622
|
+
/>
|
|
623
|
+
</template>
|
|
624
|
+
|
|
625
|
+
<!-- Default Cell -->
|
|
626
|
+
<template v-else>
|
|
627
|
+
{{ getCellValue(row, col) }}
|
|
628
|
+
</template>
|
|
629
|
+
</td>
|
|
630
|
+
</tr>
|
|
631
|
+
</template>
|
|
632
|
+
</tbody>
|
|
633
|
+
</table>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<!-- Footer / Pagination -->
|
|
637
|
+
<div
|
|
638
|
+
v-if="paginated && !loading && paginatedData.length > 0"
|
|
639
|
+
class="px-4 py-3 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between bg-gray-50/50 dark:bg-gray-800/30"
|
|
640
|
+
>
|
|
641
|
+
<div class="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
|
642
|
+
{{ showingText }}
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
<div class="flex items-center gap-4">
|
|
646
|
+
<!-- Page Size Selector -->
|
|
647
|
+
<div v-if="showPageSize" class="flex items-center gap-2">
|
|
648
|
+
<span class="text-sm text-gray-500 dark:text-gray-400">Show</span>
|
|
649
|
+
<USelectMenu
|
|
650
|
+
:model-value="internalPageSize"
|
|
651
|
+
:items="pageSizes.map(s => ({ label: String(s), value: s }))"
|
|
652
|
+
size="sm"
|
|
653
|
+
class="w-20"
|
|
654
|
+
@update:model-value="handlePageSizeChange"
|
|
655
|
+
/>
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
<!-- Pagination -->
|
|
659
|
+
<UPagination
|
|
660
|
+
:model-value="currentPage"
|
|
661
|
+
:total="totalRows"
|
|
662
|
+
:page-count="internalPageSize"
|
|
663
|
+
size="sm"
|
|
664
|
+
:ui="{
|
|
665
|
+
wrapper: 'gap-1',
|
|
666
|
+
base: 'min-w-8 h-8',
|
|
667
|
+
rounded: 'rounded-md'
|
|
668
|
+
}"
|
|
669
|
+
@update:model-value="handlePageChange"
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
</template>
|