@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.
Files changed (136) hide show
  1. package/README.md +42 -0
  2. package/app/app.vue +7 -0
  3. package/app/assets/css/v-app.css +313 -0
  4. package/app/components/V/A/Badge.vue +75 -0
  5. package/app/components/V/A/Btn/Add.vue +17 -0
  6. package/app/components/V/A/Btn/Back.vue +25 -0
  7. package/app/components/V/A/Btn/ConfirmDelete.vue +45 -0
  8. package/app/components/V/A/Btn/Edit.vue +35 -0
  9. package/app/components/V/A/Btn/Export.vue +28 -0
  10. package/app/components/V/A/Btn/Refresh.vue +21 -0
  11. package/app/components/V/A/Btn/Submit.vue +45 -0
  12. package/app/components/V/A/Btn/View.vue +23 -0
  13. package/app/components/V/A/Card.legacy.vue +291 -0
  14. package/app/components/V/A/Card.vue +108 -0
  15. package/app/components/V/A/CompanyMenu.vue +83 -0
  16. package/app/components/V/A/Data/KeyValue.vue +98 -0
  17. package/app/components/V/A/Data/StatusBadge.vue +44 -0
  18. package/app/components/V/A/DataField.vue +140 -0
  19. package/app/components/V/A/DataGrid.vue +43 -0
  20. package/app/components/V/A/DataTable.vue +144 -0
  21. package/app/components/V/A/EmptyState.vue +154 -0
  22. package/app/components/V/A/Fmt/Currency.vue +36 -0
  23. package/app/components/V/A/Fmt/DateTime.vue +34 -0
  24. package/app/components/V/A/Fmt/Percent.vue +47 -0
  25. package/app/components/V/A/LoadingState.vue +140 -0
  26. package/app/components/V/A/MetricCard.vue +129 -0
  27. package/app/components/V/A/Modal/Base.vue +195 -0
  28. package/app/components/V/A/Modal/Confirm.vue +92 -0
  29. package/app/components/V/A/Modal/Form.vue +105 -0
  30. package/app/components/V/A/Navigation.vue +110 -0
  31. package/app/components/V/A/QuickActions.vue +169 -0
  32. package/app/components/V/A/Slide.vue +109 -0
  33. package/app/components/V/A/Slideover.vue +259 -0
  34. package/app/components/V/A/State/Empty.vue +20 -0
  35. package/app/components/V/A/State/Error.vue +34 -0
  36. package/app/components/V/A/State/Loading.vue +33 -0
  37. package/app/components/V/A/StatsCard.vue +215 -0
  38. package/app/components/V/A/StatusBadge.vue +215 -0
  39. package/app/components/V/A/Table.vue +674 -0
  40. package/app/components/V/A/UserMenu.vue +127 -0
  41. package/app/components/V/A/WelcomeHeader.vue +96 -0
  42. package/app/components/V/Modal.vue +36 -0
  43. package/app/components/Va/Blocks/VaBlockGridCharts.vue +32 -0
  44. package/app/components/Va/Blocks/VaBlockGridKPI.vue +32 -0
  45. package/app/components/Va/Blocks/VaBlockGridTables.vue +23 -0
  46. package/app/components/Va/Blocks/VaBlockKpiGrid.vue +8 -0
  47. package/app/components/Va/Blocks/VaBlockSessionFilterBar.vue +8 -0
  48. package/app/components/Va/Cards/VaCardDonutChart.vue +59 -0
  49. package/app/components/Va/Cards/VaCardHeader.vue +10 -0
  50. package/app/components/Va/Cards/VaCardKpi.vue +17 -0
  51. package/app/components/Va/Cards/VaCardKpi2.vue +55 -0
  52. package/app/components/Va/Cards/VaCardLatestOrders.vue +82 -0
  53. package/app/components/Va/Cards/VaCardPopularProducts.vue +88 -0
  54. package/app/components/Va/Cards/VaCardRevenueBarChart.vue +49 -0
  55. package/app/components/Va/Cards/VaCardSubtitle.vue +5 -0
  56. package/app/components/Va/Cards/VaCardTitle.vue +5 -0
  57. package/app/components/Va/Cards/VaCardWithActiveUsers.vue +41 -0
  58. package/app/components/Va/Cards/VaCardWithChart.vue +135 -0
  59. package/app/components/Va/Cards/VaCardWithChartBlock.vue +26 -0
  60. package/app/components/Va/Cards/VaCardWithIndicator.vue +39 -0
  61. package/app/components/Va/Cards/VaCardWithProgressCircle.vue +34 -0
  62. package/app/components/Va/Cards/types.ts +11 -0
  63. package/app/components/Va/Charts/VaChartAppPerformanceBar.vue +118 -0
  64. package/app/components/Va/Charts/VaChartAppPerformanceBarChart.vue +118 -0
  65. package/app/components/Va/Charts/VaChartAreaMini.vue +127 -0
  66. package/app/components/Va/Charts/VaChartBarMini.vue +68 -0
  67. package/app/components/Va/Charts/VaChartCardinalMulti.vue +108 -0
  68. package/app/components/Va/Charts/VaChartColorBarChart.vue +78 -0
  69. package/app/components/Va/Charts/VaChartDonutHalf.vue +35 -0
  70. package/app/components/Va/Charts/VaChartDonutMini.vue +77 -0
  71. package/app/components/Va/Charts/VaChartExpensesBar.vue +58 -0
  72. package/app/components/Va/Charts/VaChartFinanceSummary.vue +96 -0
  73. package/app/components/Va/Charts/VaChartGoogleSearchConsole.vue +90 -0
  74. package/app/components/Va/Charts/VaChartIncomeBar.vue +82 -0
  75. package/app/components/Va/Charts/VaChartLegend.vue +25 -0
  76. package/app/components/Va/Charts/VaChartLineMini.vue +205 -0
  77. package/app/components/Va/Charts/VaChartRealtimeTraffic.vue +182 -0
  78. package/app/components/Va/Charts/VaChartRevenue.vue +43 -0
  79. package/app/components/Va/Charts/VaChartRevenueLine.vue +42 -0
  80. package/app/components/Va/Charts/VaChartRevenuevsCost.vue +84 -0
  81. package/app/components/Va/Charts/VaChartSearchIntent.vue +179 -0
  82. package/app/components/Va/Charts/VaChartSpendingTrend.vue +127 -0
  83. package/app/components/Va/Charts/VaChartStackedHorizontal.vue +64 -0
  84. package/app/components/Va/Charts/VaChartStepMinimal.vue +109 -0
  85. package/app/components/Va/Charts/VaChartStockComparisonLine.vue +86 -0
  86. package/app/components/Va/Charts/VaChartStocksPortfolioLine.vue +161 -0
  87. package/app/components/Va/Charts/VaChartStocksSectorLine.vue +223 -0
  88. package/app/components/Va/Charts/VaChartTasksCategories.vue +96 -0
  89. package/app/components/Va/Charts/VaChartTasksProgress.vue +130 -0
  90. package/app/components/Va/Charts/VaChartTrafficOverview.vue +112 -0
  91. package/app/components/Va/Charts/VaChartWebPerformanceLineChart.vue +114 -0
  92. package/app/components/Va/Charts/VaChartWinLostBar.vue +110 -0
  93. package/app/components/Va/Charts/VaChartWinLostDonut.vue +107 -0
  94. package/app/components/Va/Charts/VaChartWinLostLine.vue +111 -0
  95. package/app/components/Va/Charts/types.ts +10 -0
  96. package/app/components/Va/Dashboard/Navigation/types.ts +8 -0
  97. package/app/components/Va/Dashboard/VaDashboardKPICard.vue +31 -0
  98. package/app/components/Va/Dashboard/VaDashboardNavigation.vue +50 -0
  99. package/app/components/Va/Dashboard/VaDashboardPricePlan.vue +102 -0
  100. package/app/components/Va/Dashboard/VaDashboardUsageChart.vue +84 -0
  101. package/app/components/Va/Dashboard/VaDashboardUsageRequestChart.vue +46 -0
  102. package/app/components/Va/Layout/NotificationsSlideover.vue +169 -0
  103. package/app/components/Va/Layout/SideNav/types.ts +5 -0
  104. package/app/components/Va/Layout/SideNav.vue +108 -0
  105. package/app/components/Va/Layout/TeamsMenu.vue +57 -0
  106. package/app/components/Va/Layout/UserMenu.vue +57 -0
  107. package/app/composables/useDashboard.ts +25 -0
  108. package/app/composables/useVAAnimation.ts +324 -0
  109. package/app/composables/useVAUtils.ts +118 -0
  110. package/app/composables/useVCrud.ts +647 -0
  111. package/app/composables/useVFetch.ts +46 -0
  112. package/app/composables/useVFileUpload.ts +45 -0
  113. package/app/composables/useVToast.ts +73 -0
  114. package/app/composables/useXATableColumns.ts +456 -0
  115. package/app/data/BillingStats.ts +65 -0
  116. package/app/data/SearchData.ts +58 -0
  117. package/app/data/TasksData.ts +101 -0
  118. package/app/data/dashboardData.ts +113 -0
  119. package/app/layouts/default.vue +171 -0
  120. package/app/layouts/legacy.vue +61 -0
  121. package/app/pages/playground/base.vue +498 -0
  122. package/app/pages/playground/blocks.vue +108 -0
  123. package/app/pages/playground/buttons.vue +237 -0
  124. package/app/pages/playground/cards.vue +326 -0
  125. package/app/pages/playground/charts.vue +338 -0
  126. package/app/pages/playground/dashboard.vue +315 -0
  127. package/app/pages/playground/formatters.vue +329 -0
  128. package/app/pages/playground/index.vue +109 -0
  129. package/app/pages/playground/layout.vue +159 -0
  130. package/app/pages/playground/modals.vue +606 -0
  131. package/app/pages/playground/states.vue +282 -0
  132. package/app/pages/playground/tables.vue +618 -0
  133. package/app/pages/test-layout.vue +10 -0
  134. package/nuxt.config.ts +12 -0
  135. package/package.json +71 -0
  136. 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
+ }