@veristone/nuxt-v-app 0.2.2 → 0.2.4
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/app/components/V/A/Crud/Delete.vue +1 -0
- package/app/components/V/A/CrudTable/index.vue +486 -0
- package/app/components/V/A/Table/ActionColumn.vue +133 -0
- package/app/components/V/A/Table/Actions.vue +79 -0
- package/app/components/V/A/Table/CellRenderer.vue +198 -0
- package/app/components/V/A/Table/ColumnToggle.vue +131 -0
- package/app/components/V/A/Table/EditableCell.vue +176 -0
- package/app/components/V/A/Table/Export.vue +154 -0
- package/app/components/V/A/Table/FilterBar.vue +140 -0
- package/app/components/V/A/Table/FilterChips.vue +107 -0
- package/app/components/V/A/Table/README.md +380 -0
- package/app/components/V/A/Table/Toolbar.vue +163 -0
- package/app/components/V/A/Table/index.vue +483 -0
- package/app/composables/useDataTable.js +169 -0
- package/app/composables/useXATableColumns.ts +279 -386
- package/app/pages/playground/tables.vue +182 -553
- package/app/types/table.ts +52 -0
- package/package.json +4 -2
- package/app/components/V/A/Table.vue +0 -674
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useTimeAgo } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VATableCellRenderer - Renders cell content based on column preset
|
|
6
|
+
*
|
|
7
|
+
* Automatically formats values based on column meta.preset:
|
|
8
|
+
* - text: Plain text (default)
|
|
9
|
+
* - email: Clickable email link
|
|
10
|
+
* - badge: Status badge with color mapping
|
|
11
|
+
* - date: Formatted date/time
|
|
12
|
+
* - currency: Formatted currency
|
|
13
|
+
* - number: Formatted number
|
|
14
|
+
* - boolean: Checkbox/icon
|
|
15
|
+
* - avatar: Avatar with optional name
|
|
16
|
+
* - link: Clickable URL
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
defineOptions({
|
|
20
|
+
name: 'VATableCellRenderer'
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
value: {
|
|
25
|
+
type: [String, Number, Boolean, Date, Object, Array],
|
|
26
|
+
default: null
|
|
27
|
+
},
|
|
28
|
+
column: {
|
|
29
|
+
type: Object,
|
|
30
|
+
required: true
|
|
31
|
+
},
|
|
32
|
+
row: {
|
|
33
|
+
type: Object,
|
|
34
|
+
default: () => ({})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Get preset from column meta
|
|
39
|
+
const preset = computed(() => props.column?.meta?.preset || 'text')
|
|
40
|
+
|
|
41
|
+
// Default badge color mappings
|
|
42
|
+
const defaultColorMap = {
|
|
43
|
+
active: 'success',
|
|
44
|
+
inactive: 'neutral',
|
|
45
|
+
pending: 'warning',
|
|
46
|
+
approved: 'success',
|
|
47
|
+
rejected: 'error',
|
|
48
|
+
completed: 'success',
|
|
49
|
+
cancelled: 'error',
|
|
50
|
+
draft: 'neutral',
|
|
51
|
+
published: 'success',
|
|
52
|
+
archived: 'neutral',
|
|
53
|
+
enabled: 'success',
|
|
54
|
+
disabled: 'neutral',
|
|
55
|
+
true: 'success',
|
|
56
|
+
false: 'neutral',
|
|
57
|
+
yes: 'success',
|
|
58
|
+
no: 'neutral'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Format date based on format option
|
|
62
|
+
function formatDate(value, format) {
|
|
63
|
+
if (!value) return '—'
|
|
64
|
+
const date = new Date(value)
|
|
65
|
+
if (isNaN(date.getTime())) return value
|
|
66
|
+
|
|
67
|
+
switch (format) {
|
|
68
|
+
case 'relative':
|
|
69
|
+
return useTimeAgo(date).value
|
|
70
|
+
case 'time':
|
|
71
|
+
return date.toLocaleTimeString()
|
|
72
|
+
case 'datetime':
|
|
73
|
+
return date.toLocaleString()
|
|
74
|
+
case 'date':
|
|
75
|
+
default:
|
|
76
|
+
return date.toLocaleDateString()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Format currency
|
|
81
|
+
function formatCurrency(value, currency = 'USD', locale = 'en-US') {
|
|
82
|
+
if (value == null) return '—'
|
|
83
|
+
return new Intl.NumberFormat(locale, {
|
|
84
|
+
style: 'currency',
|
|
85
|
+
currency
|
|
86
|
+
}).format(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Format number
|
|
90
|
+
function formatNumber(value, decimals, locale = 'en-US') {
|
|
91
|
+
if (value == null) return '—'
|
|
92
|
+
const opts = decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {}
|
|
93
|
+
return new Intl.NumberFormat(locale, opts).format(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get badge color from value
|
|
97
|
+
function getBadgeColor(value) {
|
|
98
|
+
const colorMap = props.column?.meta?.colorMap || {}
|
|
99
|
+
const key = String(value).toLowerCase()
|
|
100
|
+
return colorMap[key] || colorMap[value] || defaultColorMap[key] || 'neutral'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Formatted display value
|
|
104
|
+
const displayValue = computed(() => {
|
|
105
|
+
const val = props.value
|
|
106
|
+
const meta = props.column?.meta || {}
|
|
107
|
+
|
|
108
|
+
switch (preset.value) {
|
|
109
|
+
case 'email':
|
|
110
|
+
return val || '—'
|
|
111
|
+
case 'date':
|
|
112
|
+
return formatDate(val, meta.format)
|
|
113
|
+
case 'currency':
|
|
114
|
+
return formatCurrency(val, meta.currency, meta.locale)
|
|
115
|
+
case 'number':
|
|
116
|
+
return formatNumber(val, meta.decimals, meta.locale)
|
|
117
|
+
case 'boolean':
|
|
118
|
+
return '' // Icon only
|
|
119
|
+
case 'badge':
|
|
120
|
+
return val || '—'
|
|
121
|
+
case 'link':
|
|
122
|
+
return val || '—'
|
|
123
|
+
case 'avatar':
|
|
124
|
+
return meta.showName !== false ? (props.row?.name || props.row?.title || val) : ''
|
|
125
|
+
default:
|
|
126
|
+
return val ?? '—'
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Avatar URL (for avatar preset)
|
|
131
|
+
const avatarUrl = computed(() => {
|
|
132
|
+
if (preset.value !== 'avatar') return null
|
|
133
|
+
const val = props.value
|
|
134
|
+
// If value is a string URL, use it directly
|
|
135
|
+
if (typeof val === 'string' && (val.startsWith('http') || val.startsWith('/'))) {
|
|
136
|
+
return val
|
|
137
|
+
}
|
|
138
|
+
// If value is an object with avatar/image/url property
|
|
139
|
+
if (val && typeof val === 'object') {
|
|
140
|
+
return val.avatar || val.image || val.url || val.src
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
})
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<!-- Email -->
|
|
148
|
+
<a
|
|
149
|
+
v-if="preset === 'email' && value"
|
|
150
|
+
:href="`mailto:${value}`"
|
|
151
|
+
class="text-primary-600 dark:text-primary-400 hover:underline"
|
|
152
|
+
>
|
|
153
|
+
{{ displayValue }}
|
|
154
|
+
</a>
|
|
155
|
+
|
|
156
|
+
<!-- Link -->
|
|
157
|
+
<a
|
|
158
|
+
v-else-if="preset === 'link' && value"
|
|
159
|
+
:href="value"
|
|
160
|
+
target="_blank"
|
|
161
|
+
rel="noopener noreferrer"
|
|
162
|
+
class="text-primary-600 dark:text-primary-400 hover:underline"
|
|
163
|
+
>
|
|
164
|
+
{{ displayValue }}
|
|
165
|
+
</a>
|
|
166
|
+
|
|
167
|
+
<!-- Badge -->
|
|
168
|
+
<UBadge
|
|
169
|
+
v-else-if="preset === 'badge'"
|
|
170
|
+
:color="getBadgeColor(value)"
|
|
171
|
+
variant="subtle"
|
|
172
|
+
class="capitalize"
|
|
173
|
+
>
|
|
174
|
+
{{ displayValue }}
|
|
175
|
+
</UBadge>
|
|
176
|
+
|
|
177
|
+
<!-- Boolean -->
|
|
178
|
+
<UIcon
|
|
179
|
+
v-else-if="preset === 'boolean'"
|
|
180
|
+
:name="value ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
|
|
181
|
+
:class="value ? 'text-success-500 w-5 h-5' : 'text-neutral-400 w-5 h-5'"
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
<!-- Avatar -->
|
|
185
|
+
<div v-else-if="preset === 'avatar'" class="flex items-center gap-2">
|
|
186
|
+
<UAvatar
|
|
187
|
+
:src="avatarUrl"
|
|
188
|
+
:alt="displayValue"
|
|
189
|
+
:size="column?.meta?.size || 'xs'"
|
|
190
|
+
/>
|
|
191
|
+
<span v-if="column?.meta?.showName !== false">{{ displayValue }}</span>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Default: text, date, currency, number -->
|
|
195
|
+
<span v-else :class="preset === 'number' || preset === 'currency' ? 'tabular-nums' : ''">
|
|
196
|
+
{{ displayValue }}
|
|
197
|
+
</span>
|
|
198
|
+
</template>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<UPopover>
|
|
3
|
+
<UButton
|
|
4
|
+
icon="i-lucide-columns-3"
|
|
5
|
+
color="neutral"
|
|
6
|
+
variant="outline"
|
|
7
|
+
size="sm"
|
|
8
|
+
/>
|
|
9
|
+
|
|
10
|
+
<template #content>
|
|
11
|
+
<div class="p-3 w-56">
|
|
12
|
+
<header class="flex items-center justify-between mb-3 pb-2 border-b border-neutral-200 dark:border-neutral-700">
|
|
13
|
+
<span class="text-sm font-medium">Columns</span>
|
|
14
|
+
<div class="flex gap-1">
|
|
15
|
+
<UButton
|
|
16
|
+
label="All"
|
|
17
|
+
size="xs"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
color="neutral"
|
|
20
|
+
@click="showAll"
|
|
21
|
+
/>
|
|
22
|
+
<UButton
|
|
23
|
+
label="Reset"
|
|
24
|
+
size="xs"
|
|
25
|
+
variant="ghost"
|
|
26
|
+
color="neutral"
|
|
27
|
+
@click="reset"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</header>
|
|
31
|
+
|
|
32
|
+
<ul class="space-y-1">
|
|
33
|
+
<li v-for="col in columns" :key="getColumnId(col)">
|
|
34
|
+
<label class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer">
|
|
35
|
+
<UCheckbox
|
|
36
|
+
:model-value="isVisible(col)"
|
|
37
|
+
@update:model-value="toggleColumn(col, $event)"
|
|
38
|
+
/>
|
|
39
|
+
<span class="text-sm">{{ getColumnLabel(col) }}</span>
|
|
40
|
+
</label>
|
|
41
|
+
</li>
|
|
42
|
+
</ul>
|
|
43
|
+
|
|
44
|
+
<footer v-if="hiddenCount > 0" class="mt-3 pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
|
45
|
+
<p class="text-xs text-neutral-500">
|
|
46
|
+
{{ hiddenCount }} column{{ hiddenCount === 1 ? '' : 's' }} hidden
|
|
47
|
+
</p>
|
|
48
|
+
</footer>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
</UPopover>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script setup>
|
|
55
|
+
defineOptions({
|
|
56
|
+
name: 'VATableColumnToggle'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const props = defineProps({
|
|
60
|
+
columns: {
|
|
61
|
+
type: Array,
|
|
62
|
+
required: true
|
|
63
|
+
},
|
|
64
|
+
modelValue: {
|
|
65
|
+
type: Array,
|
|
66
|
+
default: () => []
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const emit = defineEmits(['update:modelValue'])
|
|
71
|
+
|
|
72
|
+
// Get column ID
|
|
73
|
+
function getColumnId(col) {
|
|
74
|
+
return col.id || col.accessorKey || col.key
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Get column label
|
|
78
|
+
function getColumnLabel(col) {
|
|
79
|
+
return col.header || col.label || getColumnId(col)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check if column is visible
|
|
83
|
+
function isVisible(col) {
|
|
84
|
+
const id = getColumnId(col)
|
|
85
|
+
// If no model value set, all columns are visible by default
|
|
86
|
+
if (!props.modelValue?.length) return true
|
|
87
|
+
return props.modelValue.includes(id)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Toggle column visibility
|
|
91
|
+
function toggleColumn(col, visible) {
|
|
92
|
+
const id = getColumnId(col)
|
|
93
|
+
let newValue
|
|
94
|
+
|
|
95
|
+
if (!props.modelValue?.length) {
|
|
96
|
+
// First toggle - start with all columns except this one
|
|
97
|
+
if (!visible) {
|
|
98
|
+
newValue = props.columns
|
|
99
|
+
.map(c => getColumnId(c))
|
|
100
|
+
.filter(cid => cid !== id)
|
|
101
|
+
} else {
|
|
102
|
+
return // Already all visible
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
if (visible) {
|
|
106
|
+
newValue = [...props.modelValue, id]
|
|
107
|
+
} else {
|
|
108
|
+
newValue = props.modelValue.filter(cid => cid !== id)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
emit('update:modelValue', newValue)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Show all columns
|
|
116
|
+
function showAll() {
|
|
117
|
+
const allIds = props.columns.map(c => getColumnId(c))
|
|
118
|
+
emit('update:modelValue', allIds)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Reset to default (all visible)
|
|
122
|
+
function reset() {
|
|
123
|
+
emit('update:modelValue', [])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Count hidden columns
|
|
127
|
+
const hiddenCount = computed(() => {
|
|
128
|
+
if (!props.modelValue?.length) return 0
|
|
129
|
+
return props.columns.length - props.modelValue.length
|
|
130
|
+
})
|
|
131
|
+
</script>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="group relative"
|
|
4
|
+
:class="{ 'cursor-pointer': editable && !isEditing }"
|
|
5
|
+
@dblclick="startEditing"
|
|
6
|
+
>
|
|
7
|
+
<!-- Display Mode -->
|
|
8
|
+
<template v-if="!isEditing">
|
|
9
|
+
<div class="flex items-center gap-1">
|
|
10
|
+
<slot>
|
|
11
|
+
<span :class="displayClass">{{ displayValue }}</span>
|
|
12
|
+
</slot>
|
|
13
|
+
<UIcon
|
|
14
|
+
v-if="editable"
|
|
15
|
+
name="i-lucide-pencil"
|
|
16
|
+
class="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<!-- Edit Mode -->
|
|
22
|
+
<template v-else>
|
|
23
|
+
<!-- Text Input -->
|
|
24
|
+
<UInput
|
|
25
|
+
v-if="type === 'text' || type === 'email' || type === 'number'"
|
|
26
|
+
ref="inputRef"
|
|
27
|
+
v-model="editValue"
|
|
28
|
+
:type="type"
|
|
29
|
+
size="xs"
|
|
30
|
+
class="w-full"
|
|
31
|
+
@keydown.enter="saveEdit"
|
|
32
|
+
@keydown.escape="cancelEdit"
|
|
33
|
+
@blur="onBlur"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<!-- Textarea -->
|
|
37
|
+
<UTextarea
|
|
38
|
+
v-else-if="type === 'textarea'"
|
|
39
|
+
ref="inputRef"
|
|
40
|
+
v-model="editValue"
|
|
41
|
+
rows="2"
|
|
42
|
+
size="xs"
|
|
43
|
+
class="w-full"
|
|
44
|
+
@keydown.escape="cancelEdit"
|
|
45
|
+
@blur="onBlur"
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<!-- Select -->
|
|
49
|
+
<USelect
|
|
50
|
+
v-else-if="type === 'select'"
|
|
51
|
+
ref="inputRef"
|
|
52
|
+
v-model="editValue"
|
|
53
|
+
:items="options"
|
|
54
|
+
size="xs"
|
|
55
|
+
class="w-full"
|
|
56
|
+
@update:model-value="saveEdit"
|
|
57
|
+
@keydown.escape="cancelEdit"
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<!-- Checkbox -->
|
|
61
|
+
<UCheckbox
|
|
62
|
+
v-else-if="type === 'boolean'"
|
|
63
|
+
v-model="editValue"
|
|
64
|
+
@update:model-value="saveEdit"
|
|
65
|
+
/>
|
|
66
|
+
</template>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<script setup>
|
|
71
|
+
defineOptions({
|
|
72
|
+
name: 'VATableEditableCell'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const props = defineProps({
|
|
76
|
+
value: {
|
|
77
|
+
type: [String, Number, Boolean, Object, Array],
|
|
78
|
+
default: null
|
|
79
|
+
},
|
|
80
|
+
editable: {
|
|
81
|
+
type: Boolean,
|
|
82
|
+
default: true
|
|
83
|
+
},
|
|
84
|
+
type: {
|
|
85
|
+
type: String,
|
|
86
|
+
default: 'text',
|
|
87
|
+
validator: value => ['text', 'email', 'number', 'textarea', 'select', 'boolean'].includes(value)
|
|
88
|
+
},
|
|
89
|
+
options: {
|
|
90
|
+
type: Array,
|
|
91
|
+
default: () => []
|
|
92
|
+
},
|
|
93
|
+
displayClass: {
|
|
94
|
+
type: String,
|
|
95
|
+
default: ''
|
|
96
|
+
},
|
|
97
|
+
formatter: {
|
|
98
|
+
type: Function,
|
|
99
|
+
default: null
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const emit = defineEmits(['save', 'cancel'])
|
|
104
|
+
|
|
105
|
+
const inputRef = ref(null)
|
|
106
|
+
const isEditing = ref(false)
|
|
107
|
+
const editValue = ref(props.value)
|
|
108
|
+
|
|
109
|
+
// Display value with optional formatter
|
|
110
|
+
const displayValue = computed(() => {
|
|
111
|
+
if (props.formatter) {
|
|
112
|
+
return props.formatter(props.value)
|
|
113
|
+
}
|
|
114
|
+
if (props.value === null || props.value === undefined) {
|
|
115
|
+
return '-'
|
|
116
|
+
}
|
|
117
|
+
if (props.type === 'boolean') {
|
|
118
|
+
return props.value ? 'Yes' : 'No'
|
|
119
|
+
}
|
|
120
|
+
return String(props.value)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Start editing
|
|
124
|
+
function startEditing() {
|
|
125
|
+
if (!props.editable) return
|
|
126
|
+
editValue.value = props.value
|
|
127
|
+
isEditing.value = true
|
|
128
|
+
|
|
129
|
+
nextTick(() => {
|
|
130
|
+
if (inputRef.value && inputRef.value.focus) {
|
|
131
|
+
inputRef.value.focus()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Save edit
|
|
137
|
+
function saveEdit() {
|
|
138
|
+
if (editValue.value !== props.value) {
|
|
139
|
+
emit('save', editValue.value, props.value)
|
|
140
|
+
}
|
|
141
|
+
isEditing.value = false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Cancel edit
|
|
145
|
+
function cancelEdit() {
|
|
146
|
+
editValue.value = props.value
|
|
147
|
+
isEditing.value = false
|
|
148
|
+
emit('cancel')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle blur - save on blur unless escape was pressed
|
|
152
|
+
function onBlur() {
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
if (isEditing.value) {
|
|
155
|
+
saveEdit()
|
|
156
|
+
}
|
|
157
|
+
}, 100)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Watch for external value changes
|
|
161
|
+
watch(
|
|
162
|
+
() => props.value,
|
|
163
|
+
(newValue) => {
|
|
164
|
+
if (!isEditing.value) {
|
|
165
|
+
editValue.value = newValue
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Expose methods for parent components
|
|
171
|
+
defineExpose({
|
|
172
|
+
startEditing,
|
|
173
|
+
cancelEdit,
|
|
174
|
+
isEditing
|
|
175
|
+
})
|
|
176
|
+
</script>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<UDropdownMenu :items="exportMenuItems">
|
|
3
|
+
<UButton
|
|
4
|
+
:label="showLabel ? 'Export' : undefined"
|
|
5
|
+
icon="i-lucide-download"
|
|
6
|
+
color="neutral"
|
|
7
|
+
variant="outline"
|
|
8
|
+
size="sm"
|
|
9
|
+
:trailing-icon="showLabel ? 'i-lucide-chevron-down' : undefined"
|
|
10
|
+
/>
|
|
11
|
+
</UDropdownMenu>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup>
|
|
15
|
+
defineOptions({
|
|
16
|
+
name: 'VATableExport'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
data: {
|
|
21
|
+
type: Array,
|
|
22
|
+
required: true
|
|
23
|
+
},
|
|
24
|
+
columns: {
|
|
25
|
+
type: Array,
|
|
26
|
+
required: true
|
|
27
|
+
},
|
|
28
|
+
filename: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: 'export'
|
|
31
|
+
},
|
|
32
|
+
showLabel: {
|
|
33
|
+
type: Boolean,
|
|
34
|
+
default: true
|
|
35
|
+
},
|
|
36
|
+
enableCsv: {
|
|
37
|
+
type: Boolean,
|
|
38
|
+
default: true
|
|
39
|
+
},
|
|
40
|
+
enableJson: {
|
|
41
|
+
type: Boolean,
|
|
42
|
+
default: false
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits(['export'])
|
|
47
|
+
|
|
48
|
+
// Build menu items
|
|
49
|
+
const exportMenuItems = computed(() => {
|
|
50
|
+
const items = []
|
|
51
|
+
|
|
52
|
+
if (props.enableCsv) {
|
|
53
|
+
items.push({
|
|
54
|
+
label: 'Export as CSV',
|
|
55
|
+
icon: 'i-lucide-file-spreadsheet',
|
|
56
|
+
onSelect: () => exportCsv()
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (props.enableJson) {
|
|
61
|
+
items.push({
|
|
62
|
+
label: 'Export as JSON',
|
|
63
|
+
icon: 'i-lucide-file-json',
|
|
64
|
+
onSelect: () => exportJson()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return items
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Format a cell value for export
|
|
72
|
+
function formatValue(value, formatter) {
|
|
73
|
+
if (formatter) {
|
|
74
|
+
return formatter(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (value === null || value === undefined) {
|
|
78
|
+
return ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof value === 'object') {
|
|
82
|
+
return JSON.stringify(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return String(value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get value from nested path (e.g., "user.name")
|
|
89
|
+
function getNestedValue(obj, path) {
|
|
90
|
+
if (typeof obj !== 'object' || obj === null) return undefined
|
|
91
|
+
|
|
92
|
+
const keys = path.split('.')
|
|
93
|
+
let current = obj
|
|
94
|
+
|
|
95
|
+
for (const key of keys) {
|
|
96
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
97
|
+
current = current[key]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return current
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Export to CSV
|
|
104
|
+
function exportCsv() {
|
|
105
|
+
// Build header row
|
|
106
|
+
const headers = props.columns.map(col => `"${col.label.replace(/"/g, '""')}"`)
|
|
107
|
+
const rows = [headers.join(',')]
|
|
108
|
+
|
|
109
|
+
// Build data rows
|
|
110
|
+
for (const item of props.data) {
|
|
111
|
+
const row = props.columns.map(col => {
|
|
112
|
+
const value = getNestedValue(item, col.key)
|
|
113
|
+
const formatted = formatValue(value, col.formatter)
|
|
114
|
+
// Escape quotes and wrap in quotes
|
|
115
|
+
return `"${formatted.replace(/"/g, '""')}"`
|
|
116
|
+
})
|
|
117
|
+
rows.push(row.join(','))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const csvContent = rows.join('\n')
|
|
121
|
+
downloadFile(csvContent, `${props.filename}.csv`, 'text/csv')
|
|
122
|
+
emit('export', 'csv', csvContent)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Export to JSON
|
|
126
|
+
function exportJson() {
|
|
127
|
+
// Build clean export data
|
|
128
|
+
const exportData = props.data.map(item => {
|
|
129
|
+
const obj = {}
|
|
130
|
+
for (const col of props.columns) {
|
|
131
|
+
const value = getNestedValue(item, col.key)
|
|
132
|
+
obj[col.key] = col.formatter ? col.formatter(value) : value
|
|
133
|
+
}
|
|
134
|
+
return obj
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const jsonContent = JSON.stringify(exportData, null, 2)
|
|
138
|
+
downloadFile(jsonContent, `${props.filename}.json`, 'application/json')
|
|
139
|
+
emit('export', 'json', jsonContent)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Download file utility
|
|
143
|
+
function downloadFile(content, filename, mimeType) {
|
|
144
|
+
const blob = new Blob([content], { type: mimeType })
|
|
145
|
+
const url = URL.createObjectURL(blob)
|
|
146
|
+
const link = document.createElement('a')
|
|
147
|
+
link.href = url
|
|
148
|
+
link.download = filename
|
|
149
|
+
document.body.appendChild(link)
|
|
150
|
+
link.click()
|
|
151
|
+
document.body.removeChild(link)
|
|
152
|
+
URL.revokeObjectURL(url)
|
|
153
|
+
}
|
|
154
|
+
</script>
|