@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,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVCrud - Veristone CRUD Composable
|
|
3
|
+
* Optimized for V-App data flows.
|
|
4
|
+
*
|
|
5
|
+
* Uses $fetch for mutations (POST, PUT, PATCH, DELETE) to avoid Nuxt's useFetch caching issues.
|
|
6
|
+
* Uses useVFetch only for initial data loading (SSR compatible).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useVToast } from './useVToast'
|
|
10
|
+
|
|
11
|
+
// --------------------------------------------
|
|
12
|
+
// Route + cache helpers (V-app specific)
|
|
13
|
+
// --------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const ROUTE_ID_PARAMS = ['id', 'slug', 'uuid', 'itemId', 'recordId'] as const
|
|
16
|
+
|
|
17
|
+
export function looksLikeId(segment: string): boolean {
|
|
18
|
+
if (!segment) return false
|
|
19
|
+
|
|
20
|
+
// Common ID formats we see across services:
|
|
21
|
+
// - UUID v4 (and friends)
|
|
22
|
+
// - CUID
|
|
23
|
+
// - numeric IDs
|
|
24
|
+
// - "nanoid-like" strings
|
|
25
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)
|
|
26
|
+
const isCuid = /^c[a-z0-9]{24}$/i.test(segment)
|
|
27
|
+
const isMongoish = /^[a-z0-9]{24}$/i.test(segment)
|
|
28
|
+
const isNumeric = /^\d+$/.test(segment)
|
|
29
|
+
const isNanoish = /^[a-zA-Z0-9_-]{8,26}$/.test(segment)
|
|
30
|
+
|
|
31
|
+
return isUuid || isCuid || isMongoish || isNumeric || isNanoish
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detectViewMode(currentRoute: ReturnType<typeof useRoute>): 'list' | 'detail' {
|
|
35
|
+
// 1) Explicit params: /resource/:id
|
|
36
|
+
for (const key of ROUTE_ID_PARAMS) {
|
|
37
|
+
if (currentRoute?.params?.[key]) return 'detail'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2) Any param that looks like an ID
|
|
41
|
+
for (const raw of Object.values(currentRoute?.params || {})) {
|
|
42
|
+
const val = Array.isArray(raw) ? raw[0] : raw
|
|
43
|
+
if (val && looksLikeId(String(val))) return 'detail'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3) Named routes like "users-id" etc
|
|
47
|
+
const maybeName = String(currentRoute?.name || '')
|
|
48
|
+
if (/(?:-|_)(id|slug|uuid)$/.test(maybeName) || /-(id|slug|uuid)-/.test(maybeName)) return 'detail'
|
|
49
|
+
|
|
50
|
+
// 4) Last path segment like /resource/123
|
|
51
|
+
const lastPiece = currentRoute?.path?.split('/').filter(Boolean).pop()
|
|
52
|
+
if (lastPiece && looksLikeId(lastPiece)) return 'detail'
|
|
53
|
+
|
|
54
|
+
return 'list'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function extractCacheKey(endpoint: string): string {
|
|
58
|
+
// Keep cache keys stable between environments, but strip "noise" prefixes.
|
|
59
|
+
const ignore = new Set(['api', 'rest', 'v1', 'v2', 'v3'])
|
|
60
|
+
return (endpoint || '')
|
|
61
|
+
.split('/')
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.filter(seg => !ignore.has(seg.toLowerCase()))
|
|
64
|
+
.join(':')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getIdFromRoute(
|
|
68
|
+
currentRoute: ReturnType<typeof useRoute>,
|
|
69
|
+
paramKey: string
|
|
70
|
+
): ComputedRef<string | number | undefined> {
|
|
71
|
+
return computed(() => {
|
|
72
|
+
const candidates = [paramKey, ...ROUTE_ID_PARAMS.filter(p => p !== paramKey)]
|
|
73
|
+
|
|
74
|
+
for (const name of candidates) {
|
|
75
|
+
const raw = currentRoute?.params?.[name]
|
|
76
|
+
if (!raw) continue
|
|
77
|
+
const val = Array.isArray(raw) ? raw[0] : raw
|
|
78
|
+
return val as any
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const raw of Object.values(currentRoute?.params || {})) {
|
|
82
|
+
const val = Array.isArray(raw) ? raw[0] : raw
|
|
83
|
+
if (val && looksLikeId(String(val))) return val as any
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return undefined
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getParentPath(currentRoute: ReturnType<typeof useRoute>): string {
|
|
91
|
+
const segments = (currentRoute?.path || '').split('/').filter(Boolean)
|
|
92
|
+
const actions = new Set(['edit', 'view', 'delete', 'new', 'create'])
|
|
93
|
+
|
|
94
|
+
while (segments.length > 0) {
|
|
95
|
+
const last = segments[segments.length - 1]
|
|
96
|
+
if (!last) break
|
|
97
|
+
if (actions.has(last.toLowerCase()) || looksLikeId(last)) segments.pop()
|
|
98
|
+
else break
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return segments.length ? `/${segments.join('/')}` : '/'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getResourcePath(currentRoute: ReturnType<typeof useRoute>): string {
|
|
105
|
+
// For V-app, the "resource" path is the non-detail location.
|
|
106
|
+
// Example: /clients/123/edit -> /clients
|
|
107
|
+
return getParentPath(currentRoute)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function constructUrl(endpoint: string, id?: string | number): string {
|
|
111
|
+
const base = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint
|
|
112
|
+
return id === undefined || id === null ? base : `${base}/${id}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface VCrudSort {
|
|
116
|
+
column: string
|
|
117
|
+
direction: 'asc' | 'desc'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface VCrudOptions<T = any> {
|
|
121
|
+
idKey?: string
|
|
122
|
+
paramKey?: string
|
|
123
|
+
viewMode?: 'list' | 'detail'
|
|
124
|
+
immediate?: boolean
|
|
125
|
+
enableToast?: boolean
|
|
126
|
+
initialFilters?: Record<string, any>
|
|
127
|
+
initialSort?: VCrudSort | null
|
|
128
|
+
redirectPath?: string
|
|
129
|
+
onCreateSuccess?: 'refresh' | 'navigate' | ((item: T) => void)
|
|
130
|
+
onFetch?: (data: T | T[]) => void
|
|
131
|
+
onError?: (error: any) => void
|
|
132
|
+
onCreated?: (item: T) => void
|
|
133
|
+
onUpdated?: (item: T) => void
|
|
134
|
+
onDeleted?: () => void
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {}) => {
|
|
138
|
+
const { success: showSuccess, error: showError } = useVToast()
|
|
139
|
+
const runtimeConfig = useRuntimeConfig()
|
|
140
|
+
const currentRoute = useRoute()
|
|
141
|
+
const router = useRouter()
|
|
142
|
+
|
|
143
|
+
const {
|
|
144
|
+
idKey = 'id',
|
|
145
|
+
paramKey = 'id',
|
|
146
|
+
viewMode: forcedViewMode,
|
|
147
|
+
immediate = true,
|
|
148
|
+
enableToast = true,
|
|
149
|
+
initialFilters = {},
|
|
150
|
+
initialSort = null,
|
|
151
|
+
redirectPath: redirectOverride,
|
|
152
|
+
onCreateSuccess = 'refresh',
|
|
153
|
+
onFetch,
|
|
154
|
+
onError,
|
|
155
|
+
onCreated,
|
|
156
|
+
onUpdated,
|
|
157
|
+
onDeleted,
|
|
158
|
+
} = options
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
// Renamed internal state for clarity
|
|
162
|
+
const isBusy = useState<boolean>(`v-crud-busy-${endpoint}`, () => false)
|
|
163
|
+
const lastError = useState<string | null>(`v-crud-error-${endpoint}`, () => null)
|
|
164
|
+
|
|
165
|
+
// Additional state for parity with useXCrud (kept as refs, not global useState)
|
|
166
|
+
const viewMode = computed(() => forcedViewMode || detectViewMode(currentRoute))
|
|
167
|
+
const isListView = computed(() => viewMode.value === 'list')
|
|
168
|
+
const isDetailView = computed(() => viewMode.value === 'detail')
|
|
169
|
+
|
|
170
|
+
const recordId = getIdFromRoute(currentRoute, paramKey)
|
|
171
|
+
const itemCount = ref(0)
|
|
172
|
+
const hasRecords = computed(() => itemCount.value > 0)
|
|
173
|
+
|
|
174
|
+
const isCreating = ref(false)
|
|
175
|
+
const isUpdating = ref(false)
|
|
176
|
+
const isDeleting = ref(false)
|
|
177
|
+
|
|
178
|
+
const filterValues = ref<Record<string, any>>({ ...initialFilters })
|
|
179
|
+
const searchTerm = ref('')
|
|
180
|
+
const sortConfig = ref<VCrudSort | null>(initialSort)
|
|
181
|
+
|
|
182
|
+
// Form state (useful for edit screens and drawer editors)
|
|
183
|
+
const formData = ref<Partial<T>>({}) as Ref<Partial<T>>
|
|
184
|
+
const originalFormData = ref<Partial<T>>({}) as Ref<Partial<T>>
|
|
185
|
+
|
|
186
|
+
const isFormDirty = computed(() => {
|
|
187
|
+
// Cheap deep-ish compare; good enough for typical form payloads.
|
|
188
|
+
return JSON.stringify(formData.value || {}) !== JSON.stringify(originalFormData.value || {})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const showDeleteModal = ref(false)
|
|
192
|
+
|
|
193
|
+
// Internal: keep the most recently fetched detail record for form sync
|
|
194
|
+
const detailData = ref<T | null>(null) as Ref<T | null>
|
|
195
|
+
const listData = ref<T[]>([]) as Ref<T[]>
|
|
196
|
+
|
|
197
|
+
const cacheKey = extractCacheKey(endpoint)
|
|
198
|
+
const cachePrefix = `vcrud:${cacheKey}`
|
|
199
|
+
|
|
200
|
+
const notifyOk = (title: string, message?: string) => {
|
|
201
|
+
if (!enableToast) return
|
|
202
|
+
showSuccess(title, message)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const notifyFail = (title: string, message?: string) => {
|
|
206
|
+
if (!enableToast) return
|
|
207
|
+
showError(title, message)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get auth token for mutations
|
|
211
|
+
const getAuthHeaders = () => {
|
|
212
|
+
return {
|
|
213
|
+
'Content-Type': 'application/json',
|
|
214
|
+
'X-Client-Version': 'v-app-1.0'
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Get base URL
|
|
219
|
+
const getBaseUrl = () => (runtimeConfig.public.apiUrl as string) || '/'
|
|
220
|
+
|
|
221
|
+
const getMutationHeaders = (body: any) => {
|
|
222
|
+
const headers = { ...getAuthHeaders() }
|
|
223
|
+
// If sending multipart, let fetch set boundary headers.
|
|
224
|
+
if (body instanceof FormData) {
|
|
225
|
+
delete (headers as any)['Content-Type']
|
|
226
|
+
}
|
|
227
|
+
return headers
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Internal helper for standardized error handling
|
|
231
|
+
const handleError = (action: string, err: any) => {
|
|
232
|
+
const message = err?.data?.message || err?.message || `${action} operation failed.`
|
|
233
|
+
console.error(`VCrud Error [${action}]:`, err)
|
|
234
|
+
lastError.value = message
|
|
235
|
+
if (onError) onError(err)
|
|
236
|
+
notifyFail(`${action} Failed`, message)
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Cache invalidation is opt-in per endpoint; we lean on Nuxt native primitives.
|
|
241
|
+
const clearCache = async () => {
|
|
242
|
+
clearNuxtData((key) => key.startsWith(cachePrefix))
|
|
243
|
+
|
|
244
|
+
// Refresh is best-effort: some keys may not exist in the current view.
|
|
245
|
+
try {
|
|
246
|
+
await refreshNuxtData(`${cachePrefix}:list`)
|
|
247
|
+
} catch {
|
|
248
|
+
// No-op
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
await refreshNuxtData(`${cachePrefix}:detail`)
|
|
252
|
+
} catch {
|
|
253
|
+
// No-op
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const syncFormData = (item: T) => {
|
|
258
|
+
formData.value = { ...(item as any) }
|
|
259
|
+
originalFormData.value = { ...(item as any) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const resetFormData = () => {
|
|
263
|
+
formData.value = { ...(originalFormData.value as any) }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const clearFormData = () => {
|
|
267
|
+
formData.value = {} as Partial<T>
|
|
268
|
+
originalFormData.value = {} as Partial<T>
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
watch(detailData, (next) => {
|
|
272
|
+
if (!next) return
|
|
273
|
+
if (!isDetailView.value) return
|
|
274
|
+
syncFormData(next)
|
|
275
|
+
}, { immediate: true })
|
|
276
|
+
|
|
277
|
+
const applyFilter = (key: string, value: any) => {
|
|
278
|
+
filterValues.value[key] = value
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const resetFilters = () => {
|
|
282
|
+
filterValues.value = { ...initialFilters }
|
|
283
|
+
searchTerm.value = ''
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const applySorting = (column: string, direction: 'asc' | 'desc' = 'asc') => {
|
|
287
|
+
const current = sortConfig.value
|
|
288
|
+
if (current?.column === column) {
|
|
289
|
+
sortConfig.value = {
|
|
290
|
+
column,
|
|
291
|
+
direction: direction ?? (current.direction === 'asc' ? 'desc' : 'asc')
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
sortConfig.value = { column, direction }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const resetSorting = () => {
|
|
299
|
+
sortConfig.value = initialSort
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const navigateBack = async () => {
|
|
303
|
+
const target = redirectOverride || getParentPath(currentRoute)
|
|
304
|
+
await navigateTo(target)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const navigateToEdit = async () => {
|
|
308
|
+
const id = recordId.value
|
|
309
|
+
if (!id) return
|
|
310
|
+
const base = getResourcePath(currentRoute)
|
|
311
|
+
await navigateTo(`${base}/${id}/edit`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const openDeleteConfirm = () => {
|
|
315
|
+
showDeleteModal.value = true
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const saveRecord = async () => {
|
|
319
|
+
const localId = (formData.value as any)?.[idKey] ?? recordId.value
|
|
320
|
+
if (localId !== undefined && localId !== null && String(localId) !== '') {
|
|
321
|
+
return await update(localId, formData.value)
|
|
322
|
+
}
|
|
323
|
+
return await create(formData.value)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// CREATE - Uses $fetch for post-mount compatibility
|
|
327
|
+
const create = async (payload: any) => {
|
|
328
|
+
isBusy.value = true
|
|
329
|
+
lastError.value = null
|
|
330
|
+
isCreating.value = true
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const hasFile = payload instanceof FormData || Object.values(payload || {}).some((v: any) => v instanceof File || v instanceof FileList)
|
|
334
|
+
let body: any = payload
|
|
335
|
+
|
|
336
|
+
if (hasFile && !(payload instanceof FormData)) {
|
|
337
|
+
body = new FormData()
|
|
338
|
+
Object.entries(payload || {}).forEach(([k, v]: any) => {
|
|
339
|
+
if (v instanceof FileList) Array.from(v).forEach(f => body.append(k, f))
|
|
340
|
+
else if (v instanceof File) body.append(k, v)
|
|
341
|
+
else if (v !== undefined && v !== null) body.append(k, String(v))
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const result = await $fetch<T>(`${getBaseUrl()}${constructUrl(endpoint)}`, {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: getMutationHeaders(body),
|
|
348
|
+
body
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
notifyOk('Success', 'Record created successfully.')
|
|
352
|
+
if (onCreated) onCreated(result)
|
|
353
|
+
|
|
354
|
+
// Post-create behavior stays simple and predictable for V-app.
|
|
355
|
+
if (onCreateSuccess === 'navigate') {
|
|
356
|
+
const newId = (result as any)?.[idKey]
|
|
357
|
+
if (newId !== undefined && newId !== null) {
|
|
358
|
+
const base = getResourcePath(currentRoute)
|
|
359
|
+
await navigateTo(`${base}/${newId}`)
|
|
360
|
+
}
|
|
361
|
+
} else if (typeof onCreateSuccess === 'function') {
|
|
362
|
+
onCreateSuccess(result)
|
|
363
|
+
} else {
|
|
364
|
+
// Default: refresh list if we're currently in list mode
|
|
365
|
+
if (isListView.value) {
|
|
366
|
+
await findAll()
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await clearCache()
|
|
371
|
+
return result
|
|
372
|
+
} catch (err) {
|
|
373
|
+
handleError('Create', err)
|
|
374
|
+
} finally {
|
|
375
|
+
isCreating.value = false
|
|
376
|
+
isBusy.value = false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// READ (List) - Uses $fetch for consistent post-mount behavior
|
|
381
|
+
const findAll = async (params: Record<string, any> = {}) => {
|
|
382
|
+
isBusy.value = true
|
|
383
|
+
lastError.value = null
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const mergedParams: Record<string, any> = {
|
|
387
|
+
...filterValues.value,
|
|
388
|
+
...params,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (searchTerm.value) mergedParams.search = searchTerm.value
|
|
392
|
+
if (sortConfig.value?.column) {
|
|
393
|
+
mergedParams.sort = sortConfig.value.column
|
|
394
|
+
mergedParams.order = sortConfig.value.direction
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const response = await $fetch<any>(`${getBaseUrl()}${constructUrl(endpoint)}`, {
|
|
398
|
+
method: 'GET',
|
|
399
|
+
headers: getAuthHeaders(),
|
|
400
|
+
params: mergedParams
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// Handle paginated responses: { data: [...], pagination: {...} }
|
|
404
|
+
// Return just the data array if response is paginated, otherwise return as-is
|
|
405
|
+
if (response && typeof response === 'object' && 'data' in response && Array.isArray(response.data)) {
|
|
406
|
+
const total = (response as any)?.pagination?.total ?? (response as any)?.total ?? (response as any)?.count
|
|
407
|
+
const data = response.data as T[]
|
|
408
|
+
listData.value = data
|
|
409
|
+
itemCount.value = typeof total === 'number' ? total : data.length
|
|
410
|
+
if (onFetch) onFetch(data)
|
|
411
|
+
return data
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const data = response as T[]
|
|
415
|
+
listData.value = Array.isArray(data) ? data : []
|
|
416
|
+
itemCount.value = Array.isArray(data) ? data.length : 0
|
|
417
|
+
if (onFetch) onFetch(listData.value)
|
|
418
|
+
return data
|
|
419
|
+
} catch (err) {
|
|
420
|
+
handleError('Fetch', err)
|
|
421
|
+
} finally {
|
|
422
|
+
isBusy.value = false
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// READ (Single) - Uses $fetch for consistent post-mount behavior
|
|
427
|
+
const findOne = async (id: string | number) => {
|
|
428
|
+
isBusy.value = true
|
|
429
|
+
lastError.value = null
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const result = await $fetch<T>(`${getBaseUrl()}${constructUrl(endpoint, id)}`, {
|
|
433
|
+
method: 'GET',
|
|
434
|
+
headers: getAuthHeaders()
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
detailData.value = result
|
|
438
|
+
syncFormData(result)
|
|
439
|
+
if (onFetch) onFetch(result)
|
|
440
|
+
return result
|
|
441
|
+
} catch (err) {
|
|
442
|
+
handleError('Fetch', err)
|
|
443
|
+
} finally {
|
|
444
|
+
isBusy.value = false
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// UPDATE - Uses $fetch for post-mount compatibility
|
|
449
|
+
const update = async (id: string | number, payload: any) => {
|
|
450
|
+
isBusy.value = true
|
|
451
|
+
lastError.value = null
|
|
452
|
+
isUpdating.value = true
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const hasFile = payload instanceof FormData || Object.values(payload || {}).some((v: any) => v instanceof File || v instanceof FileList)
|
|
456
|
+
let body: any = payload
|
|
457
|
+
|
|
458
|
+
if (hasFile && !(payload instanceof FormData)) {
|
|
459
|
+
body = new FormData()
|
|
460
|
+
Object.entries(payload || {}).forEach(([k, v]: any) => {
|
|
461
|
+
if (v instanceof FileList) Array.from(v).forEach(f => body.append(k, f))
|
|
462
|
+
else if (v instanceof File) body.append(k, v)
|
|
463
|
+
else if (v !== undefined && v !== null) body.append(k, String(v))
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const result = await $fetch<T>(`${getBaseUrl()}${constructUrl(endpoint, id)}`, {
|
|
468
|
+
method: 'PUT',
|
|
469
|
+
headers: getMutationHeaders(body),
|
|
470
|
+
body
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
detailData.value = result
|
|
474
|
+
notifyOk('Saved', 'Changes saved successfully.')
|
|
475
|
+
if (onUpdated) onUpdated(result)
|
|
476
|
+
await clearCache()
|
|
477
|
+
return result
|
|
478
|
+
} catch (err) {
|
|
479
|
+
handleError('Update', err)
|
|
480
|
+
} finally {
|
|
481
|
+
isUpdating.value = false
|
|
482
|
+
isBusy.value = false
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// PATCH - Uses $fetch for post-mount compatibility
|
|
487
|
+
const patch = async (id: string | number, payload: any) => {
|
|
488
|
+
isBusy.value = true
|
|
489
|
+
lastError.value = null
|
|
490
|
+
isUpdating.value = true
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
const hasFile = payload instanceof FormData || Object.values(payload || {}).some((v: any) => v instanceof File || v instanceof FileList)
|
|
494
|
+
let body: any = payload
|
|
495
|
+
|
|
496
|
+
if (hasFile && !(payload instanceof FormData)) {
|
|
497
|
+
body = new FormData()
|
|
498
|
+
Object.entries(payload || {}).forEach(([k, v]: any) => {
|
|
499
|
+
if (v instanceof FileList) Array.from(v).forEach(f => body.append(k, f))
|
|
500
|
+
else if (v instanceof File) body.append(k, v)
|
|
501
|
+
else if (v !== undefined && v !== null) body.append(k, String(v))
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const result = await $fetch<T>(`${getBaseUrl()}${constructUrl(endpoint, id)}`, {
|
|
506
|
+
method: 'PATCH',
|
|
507
|
+
headers: getMutationHeaders(body),
|
|
508
|
+
body
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
detailData.value = result
|
|
512
|
+
notifyOk('Saved', 'Record updated.')
|
|
513
|
+
if (onUpdated) onUpdated(result)
|
|
514
|
+
await clearCache()
|
|
515
|
+
return result
|
|
516
|
+
} catch (err) {
|
|
517
|
+
handleError('Patch', err)
|
|
518
|
+
} finally {
|
|
519
|
+
isUpdating.value = false
|
|
520
|
+
isBusy.value = false
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// DELETE - Uses $fetch for post-mount compatibility
|
|
525
|
+
const remove = async (id: string | number) => {
|
|
526
|
+
isBusy.value = true
|
|
527
|
+
lastError.value = null
|
|
528
|
+
isDeleting.value = true
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
await $fetch(`${getBaseUrl()}${constructUrl(endpoint, id)}`, {
|
|
532
|
+
method: 'DELETE',
|
|
533
|
+
headers: getAuthHeaders()
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
notifyOk('Deleted', 'Record removed permanently.')
|
|
537
|
+
if (onDeleted) onDeleted()
|
|
538
|
+
showDeleteModal.value = false
|
|
539
|
+
detailData.value = null
|
|
540
|
+
await clearCache()
|
|
541
|
+
|
|
542
|
+
if (isDetailView.value) {
|
|
543
|
+
await navigateBack()
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return true
|
|
547
|
+
} catch (err) {
|
|
548
|
+
handleError('Delete', err)
|
|
549
|
+
} finally {
|
|
550
|
+
isDeleting.value = false
|
|
551
|
+
isBusy.value = false
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// BULK DELETE - Uses $fetch for post-mount compatibility
|
|
556
|
+
const bulkRemove = async (ids: (string | number)[]) => {
|
|
557
|
+
isBusy.value = true
|
|
558
|
+
lastError.value = null
|
|
559
|
+
isDeleting.value = true
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
await $fetch(`${getBaseUrl()}${constructUrl(endpoint)}/bulk-delete`, {
|
|
563
|
+
method: 'DELETE',
|
|
564
|
+
headers: getAuthHeaders(),
|
|
565
|
+
body: { ids }
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
notifyOk('Batch Deleted', `${ids.length} records removed.`)
|
|
569
|
+
if (onDeleted) onDeleted()
|
|
570
|
+
await clearCache()
|
|
571
|
+
return true
|
|
572
|
+
} catch (err) {
|
|
573
|
+
handleError('Bulk Delete', err)
|
|
574
|
+
} finally {
|
|
575
|
+
isDeleting.value = false
|
|
576
|
+
isBusy.value = false
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Auto-refresh list view when list inputs change.
|
|
581
|
+
watch([filterValues, searchTerm, sortConfig], async () => {
|
|
582
|
+
if (!isListView.value) return
|
|
583
|
+
await findAll()
|
|
584
|
+
}, { deep: true })
|
|
585
|
+
|
|
586
|
+
// Auto-fetch detail data when route id changes.
|
|
587
|
+
watch(recordId, async (next) => {
|
|
588
|
+
if (!isDetailView.value) return
|
|
589
|
+
if (next === undefined || next === null || String(next) === '') return
|
|
590
|
+
await findOne(next)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// Initial fetch (keeps existing behavior: only fetch when called, unless opted in)
|
|
594
|
+
if (immediate) {
|
|
595
|
+
if (isListView.value) {
|
|
596
|
+
// Fire and forget to avoid blocking setup.
|
|
597
|
+
findAll().catch(() => {})
|
|
598
|
+
} else if (isDetailView.value && recordId.value !== undefined && recordId.value !== null) {
|
|
599
|
+
findOne(recordId.value).catch(() => {})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Expose API with original names to maintain compatibility
|
|
604
|
+
return {
|
|
605
|
+
create,
|
|
606
|
+
findAll,
|
|
607
|
+
findOne,
|
|
608
|
+
update,
|
|
609
|
+
patch,
|
|
610
|
+
remove,
|
|
611
|
+
bulkRemove,
|
|
612
|
+
loading: isBusy, // Alias internal 'isBusy' to public 'loading'
|
|
613
|
+
errorState: lastError, // Alias internal 'lastError' to public 'errorState'
|
|
614
|
+
|
|
615
|
+
// New exports (useXCrud parity, V-app naming)
|
|
616
|
+
viewMode,
|
|
617
|
+
isListView,
|
|
618
|
+
isDetailView,
|
|
619
|
+
recordId,
|
|
620
|
+
itemCount,
|
|
621
|
+
hasRecords,
|
|
622
|
+
isCreating,
|
|
623
|
+
isUpdating,
|
|
624
|
+
isDeleting,
|
|
625
|
+
filterValues,
|
|
626
|
+
searchTerm,
|
|
627
|
+
sortConfig,
|
|
628
|
+
applyFilter,
|
|
629
|
+
applySorting,
|
|
630
|
+
resetFilters,
|
|
631
|
+
resetSorting,
|
|
632
|
+
formData,
|
|
633
|
+
isFormDirty,
|
|
634
|
+
resetFormData,
|
|
635
|
+
clearFormData,
|
|
636
|
+
navigateBack,
|
|
637
|
+
navigateToEdit,
|
|
638
|
+
openDeleteConfirm,
|
|
639
|
+
showDeleteModal,
|
|
640
|
+
saveRecord,
|
|
641
|
+
clearCache,
|
|
642
|
+
|
|
643
|
+
// Expose router bits for advanced flows without requiring imports
|
|
644
|
+
currentRoute,
|
|
645
|
+
router,
|
|
646
|
+
}
|
|
647
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVFetch - Veristone Authenticated Fetcher
|
|
3
|
+
* Integrates with Nuxt V Auth Stack.
|
|
4
|
+
*/
|
|
5
|
+
import type { UseFetchOptions } from 'nuxt/app'
|
|
6
|
+
import { useVToast } from './useVToast'
|
|
7
|
+
|
|
8
|
+
export async function useVFetch<T>(
|
|
9
|
+
endpoint: string,
|
|
10
|
+
opts: UseFetchOptions<T> = {},
|
|
11
|
+
actionLabel: string = 'API Request'
|
|
12
|
+
) {
|
|
13
|
+
const { error: notifyError } = useVToast()
|
|
14
|
+
const runtimeConfig = useRuntimeConfig()
|
|
15
|
+
|
|
16
|
+
// Construct Base Config
|
|
17
|
+
const fetchConfig: UseFetchOptions<T> = {
|
|
18
|
+
baseURL: (runtimeConfig.public.apiUrl as string) || '/',
|
|
19
|
+
retry: 1,
|
|
20
|
+
retryStatusCodes: [408, 409, 429, 500, 502, 503, 504], // Extended retry codes
|
|
21
|
+
headers: {
|
|
22
|
+
'X-Client-Version': 'v-app-1.0',
|
|
23
|
+
...opts.headers
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Response Interceptor
|
|
27
|
+
onResponseError: async ({ response }) => {
|
|
28
|
+
const status = response.status
|
|
29
|
+
if (status === 401) {
|
|
30
|
+
notifyError('Authentication Required', 'This action requires authentication.')
|
|
31
|
+
} else if (status >= 500) {
|
|
32
|
+
console.error(`[VFetch] Server Error (${status}):`, response.statusText)
|
|
33
|
+
// notifyError('Server Error', 'Our services are experiencing issues.')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Merge options, excluding headers which we handled above
|
|
39
|
+
const { headers, ...otherOpts } = opts
|
|
40
|
+
|
|
41
|
+
// @ts-ignore - TypeScript has difficulty inferring the merged options type
|
|
42
|
+
return useFetch<T>(endpoint, {
|
|
43
|
+
...fetchConfig,
|
|
44
|
+
...otherOpts
|
|
45
|
+
})
|
|
46
|
+
}
|