@finema/finework-layer 0.2.49 → 0.2.51
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/.playground/app/pages/layout-admin/test/[id]/index.vue +286 -0
- package/.playground/app/pages/layout-admin.vue +2 -2
- package/@finema finework-layer - LLM Documentation Guide.md +566 -0
- package/API_REFERENCE.md +780 -0
- package/ARCHITECTURE.md +592 -0
- package/CHANGELOG.md +8 -0
- package/COMPONENT_EXAMPLES.md +893 -0
- package/DOCUMENTATION_INDEX.md +354 -0
- package/EXAMPLES.md +597 -0
- package/QUICK_START.md +678 -0
- package/README.md +199 -33
- package/TROUBLESHOOTING.md +646 -0
- package/app/components/Button/Back.vue +1 -1
- package/app/components/Layout/Admin/Sidebar.vue +10 -3
- package/app/components/Layout/Admin/index.vue +20 -19
- package/package.json +1 -1
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# Real-World Examples
|
|
2
|
+
|
|
3
|
+
This document contains complete, copy-paste ready examples for common use cases.
|
|
4
|
+
|
|
5
|
+
## 📋 Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [CRUD Operations](#crud-operations)
|
|
8
|
+
2. [Dashboard Page](#dashboard-page)
|
|
9
|
+
3. [User Management](#user-management)
|
|
10
|
+
4. [Data Export](#data-export)
|
|
11
|
+
5. [File Upload](#file-upload)
|
|
12
|
+
6. [Advanced Filtering](#advanced-filtering)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## CRUD Operations
|
|
17
|
+
|
|
18
|
+
### Complete CRUD Example
|
|
19
|
+
|
|
20
|
+
#### List Page
|
|
21
|
+
|
|
22
|
+
```vue
|
|
23
|
+
<!-- pages/products/index.vue -->
|
|
24
|
+
<template>
|
|
25
|
+
<LayoutAdmin label="Products" :items="navItems">
|
|
26
|
+
<div class="space-y-6">
|
|
27
|
+
<!-- Header -->
|
|
28
|
+
<div class="flex items-center justify-between">
|
|
29
|
+
<div>
|
|
30
|
+
<h1 class="text-2xl font-bold">Products</h1>
|
|
31
|
+
<p class="text-gray-600">Manage your product catalog</p>
|
|
32
|
+
</div>
|
|
33
|
+
<Button
|
|
34
|
+
label="Add Product"
|
|
35
|
+
icon="ph:plus"
|
|
36
|
+
to="/products/new"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Filters -->
|
|
41
|
+
<Card>
|
|
42
|
+
<form @submit.prevent="handleFilter" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
43
|
+
<Input
|
|
44
|
+
v-model="filters.q"
|
|
45
|
+
placeholder="Search products..."
|
|
46
|
+
icon="ph:magnifying-glass"
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<Select
|
|
50
|
+
v-model="filters.category"
|
|
51
|
+
:options="categoryOptions"
|
|
52
|
+
placeholder="Category"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
<Select
|
|
56
|
+
v-model="filters.status"
|
|
57
|
+
:options="statusOptions"
|
|
58
|
+
placeholder="Status"
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div class="flex gap-2">
|
|
62
|
+
<Button type="submit" label="Filter" class="flex-1" />
|
|
63
|
+
<Button
|
|
64
|
+
type="button"
|
|
65
|
+
label="Reset"
|
|
66
|
+
variant="outline"
|
|
67
|
+
@click="handleReset"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
</form>
|
|
71
|
+
</Card>
|
|
72
|
+
|
|
73
|
+
<!-- Products Table -->
|
|
74
|
+
<StatusBox :status="products.status.value" :data="products.data.value">
|
|
75
|
+
<template #default="{ data }">
|
|
76
|
+
<Card>
|
|
77
|
+
<Table :rows="data.items" :columns="columns">
|
|
78
|
+
<template #name="{ row }">
|
|
79
|
+
<div class="flex items-center gap-3">
|
|
80
|
+
<img
|
|
81
|
+
:src="row.image_url"
|
|
82
|
+
:alt="row.name"
|
|
83
|
+
class="w-10 h-10 rounded object-cover"
|
|
84
|
+
/>
|
|
85
|
+
<div>
|
|
86
|
+
<p class="font-medium">{{ row.name }}</p>
|
|
87
|
+
<p class="text-sm text-gray-500">{{ row.sku }}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<template #price="{ row }">
|
|
93
|
+
{{ NumberHelper.formatCurrency(row.price) }}
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<template #status="{ row }">
|
|
97
|
+
<Badge
|
|
98
|
+
:label="row.status"
|
|
99
|
+
:color="row.status === 'active' ? 'success' : 'neutral'"
|
|
100
|
+
/>
|
|
101
|
+
</template>
|
|
102
|
+
|
|
103
|
+
<template #actions="{ row }">
|
|
104
|
+
<div class="flex gap-2">
|
|
105
|
+
<ButtonActionIcon
|
|
106
|
+
icon="ph:eye"
|
|
107
|
+
:to="`/products/${row.id}`"
|
|
108
|
+
/>
|
|
109
|
+
<ButtonActionIcon
|
|
110
|
+
icon="ph:pencil-simple"
|
|
111
|
+
color="primary"
|
|
112
|
+
:to="`/products/${row.id}/edit`"
|
|
113
|
+
/>
|
|
114
|
+
<ButtonActionIcon
|
|
115
|
+
icon="ph:trash"
|
|
116
|
+
color="error"
|
|
117
|
+
@click="handleDelete(row.id)"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
121
|
+
</Table>
|
|
122
|
+
|
|
123
|
+
<div class="mt-4">
|
|
124
|
+
<Pagination
|
|
125
|
+
v-model="page"
|
|
126
|
+
:total="data.total"
|
|
127
|
+
:per-page="perPage"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
</Card>
|
|
131
|
+
</template>
|
|
132
|
+
</StatusBox>
|
|
133
|
+
</div>
|
|
134
|
+
</LayoutAdmin>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<script setup lang="ts">
|
|
138
|
+
definePageMeta({
|
|
139
|
+
middleware: ['auth', 'permissions'],
|
|
140
|
+
accessGuard: {
|
|
141
|
+
permissions: ['pmo:USER', 'pmo:ADMIN']
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
interface Product {
|
|
146
|
+
id: string
|
|
147
|
+
name: string
|
|
148
|
+
sku: string
|
|
149
|
+
price: number
|
|
150
|
+
category: string
|
|
151
|
+
status: string
|
|
152
|
+
image_url: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const page = ref(1)
|
|
156
|
+
const perPage = ref(10)
|
|
157
|
+
const filters = reactive({
|
|
158
|
+
q: '',
|
|
159
|
+
category: '',
|
|
160
|
+
status: ''
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const products = useListLoader<Product>({
|
|
164
|
+
method: 'GET',
|
|
165
|
+
url: '/api/products',
|
|
166
|
+
getRequestOptions: useRequestOptions().auth
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const loadProducts = () => {
|
|
170
|
+
products.run({
|
|
171
|
+
params: {
|
|
172
|
+
page: page.value,
|
|
173
|
+
per_page: perPage.value,
|
|
174
|
+
...filters
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const handleFilter = () => {
|
|
180
|
+
page.value = 1
|
|
181
|
+
loadProducts()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const handleReset = () => {
|
|
185
|
+
Object.assign(filters, { q: '', category: '', status: '' })
|
|
186
|
+
handleFilter()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const handleDelete = async (id: string) => {
|
|
190
|
+
const dialog = useDialog()
|
|
191
|
+
|
|
192
|
+
const confirmed = await dialog.confirm({
|
|
193
|
+
title: 'Delete Product',
|
|
194
|
+
description: 'Are you sure you want to delete this product?'
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (!confirmed) return
|
|
198
|
+
|
|
199
|
+
const deleteLoader = useObjectLoader({
|
|
200
|
+
method: 'DELETE',
|
|
201
|
+
url: `/api/products/${id}`,
|
|
202
|
+
getRequestOptions: useRequestOptions().auth
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
await deleteLoader.run()
|
|
206
|
+
|
|
207
|
+
if (deleteLoader.status.value.isSuccess) {
|
|
208
|
+
loadProducts()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
watch(page, () => loadProducts())
|
|
213
|
+
|
|
214
|
+
onMounted(() => loadProducts())
|
|
215
|
+
|
|
216
|
+
const columns = [
|
|
217
|
+
{ key: 'name', label: 'Product' },
|
|
218
|
+
{ key: 'category', label: 'Category' },
|
|
219
|
+
{ key: 'price', label: 'Price' },
|
|
220
|
+
{ key: 'status', label: 'Status' },
|
|
221
|
+
{ key: 'actions', label: 'Actions' }
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
const categoryOptions = [
|
|
225
|
+
{ label: 'All Categories', value: '' },
|
|
226
|
+
{ label: 'Electronics', value: 'electronics' },
|
|
227
|
+
{ label: 'Clothing', value: 'clothing' },
|
|
228
|
+
{ label: 'Food', value: 'food' }
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
const statusOptions = [
|
|
232
|
+
{ label: 'All Status', value: '' },
|
|
233
|
+
{ label: 'Active', value: 'active' },
|
|
234
|
+
{ label: 'Inactive', value: 'inactive' }
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
const navItems = [
|
|
238
|
+
{ label: 'Dashboard', to: '/dashboard', icon: 'i-heroicons-home' },
|
|
239
|
+
{ label: 'Products', to: '/products', icon: 'i-heroicons-shopping-bag' }
|
|
240
|
+
]
|
|
241
|
+
</script>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
#### Create/Edit Form
|
|
245
|
+
|
|
246
|
+
```vue
|
|
247
|
+
<!-- pages/products/[id]/edit.vue -->
|
|
248
|
+
<template>
|
|
249
|
+
<LayoutAdmin label="Edit Product" :items="navItems">
|
|
250
|
+
<StatusBox :status="product.status.value" :data="product.data.value">
|
|
251
|
+
<template #default="{ data }">
|
|
252
|
+
<Card>
|
|
253
|
+
<form @submit="onSubmit" class="space-y-6">
|
|
254
|
+
<!-- Product Image -->
|
|
255
|
+
<div>
|
|
256
|
+
<label class="block text-sm font-medium mb-2">Product Image</label>
|
|
257
|
+
<div class="flex items-center gap-4">
|
|
258
|
+
<img
|
|
259
|
+
:src="imagePreview || data.image_url"
|
|
260
|
+
alt="Product"
|
|
261
|
+
class="w-32 h-32 rounded object-cover"
|
|
262
|
+
/>
|
|
263
|
+
<input
|
|
264
|
+
type="file"
|
|
265
|
+
@change="handleImageChange"
|
|
266
|
+
accept="image/*"
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!-- Basic Info -->
|
|
272
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
273
|
+
<FormField name="name" label="Product Name">
|
|
274
|
+
<Input v-model="form.values.name" />
|
|
275
|
+
<FormError name="name" />
|
|
276
|
+
</FormField>
|
|
277
|
+
|
|
278
|
+
<FormField name="sku" label="SKU">
|
|
279
|
+
<Input v-model="form.values.sku" />
|
|
280
|
+
<FormError name="sku" />
|
|
281
|
+
</FormField>
|
|
282
|
+
|
|
283
|
+
<FormField name="category" label="Category">
|
|
284
|
+
<Select
|
|
285
|
+
v-model="form.values.category"
|
|
286
|
+
:options="categoryOptions"
|
|
287
|
+
/>
|
|
288
|
+
<FormError name="category" />
|
|
289
|
+
</FormField>
|
|
290
|
+
|
|
291
|
+
<FormField name="price" label="Price">
|
|
292
|
+
<Input
|
|
293
|
+
v-model="form.values.price"
|
|
294
|
+
type="number"
|
|
295
|
+
step="0.01"
|
|
296
|
+
/>
|
|
297
|
+
<FormError name="price" />
|
|
298
|
+
</FormField>
|
|
299
|
+
|
|
300
|
+
<FormField name="stock" label="Stock">
|
|
301
|
+
<Input
|
|
302
|
+
v-model="form.values.stock"
|
|
303
|
+
type="number"
|
|
304
|
+
/>
|
|
305
|
+
<FormError name="stock" />
|
|
306
|
+
</FormField>
|
|
307
|
+
|
|
308
|
+
<FormField name="status" label="Status">
|
|
309
|
+
<Select
|
|
310
|
+
v-model="form.values.status"
|
|
311
|
+
:options="statusOptions"
|
|
312
|
+
/>
|
|
313
|
+
<FormError name="status" />
|
|
314
|
+
</FormField>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<!-- Description -->
|
|
318
|
+
<FormField name="description" label="Description">
|
|
319
|
+
<Textarea
|
|
320
|
+
v-model="form.values.description"
|
|
321
|
+
rows="4"
|
|
322
|
+
/>
|
|
323
|
+
<FormError name="description" />
|
|
324
|
+
</FormField>
|
|
325
|
+
|
|
326
|
+
<!-- Actions -->
|
|
327
|
+
<div class="flex gap-2">
|
|
328
|
+
<Button
|
|
329
|
+
type="submit"
|
|
330
|
+
label="Save Changes"
|
|
331
|
+
:loading="form.isSubmitting.value"
|
|
332
|
+
/>
|
|
333
|
+
<ButtonBack to="/products" />
|
|
334
|
+
</div>
|
|
335
|
+
</form>
|
|
336
|
+
</Card>
|
|
337
|
+
</template>
|
|
338
|
+
</StatusBox>
|
|
339
|
+
</LayoutAdmin>
|
|
340
|
+
</template>
|
|
341
|
+
|
|
342
|
+
<script setup lang="ts">
|
|
343
|
+
import * as v from 'valibot'
|
|
344
|
+
import { toTypedSchema } from '@vee-validate/valibot'
|
|
345
|
+
|
|
346
|
+
definePageMeta({
|
|
347
|
+
middleware: ['auth', 'permissions'],
|
|
348
|
+
accessGuard: {
|
|
349
|
+
permissions: ['pmo:ADMIN']
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const route = useRoute()
|
|
354
|
+
const productId = route.params.id as string
|
|
355
|
+
|
|
356
|
+
const imagePreview = ref<string | null>(null)
|
|
357
|
+
const imageFile = ref<File | null>(null)
|
|
358
|
+
|
|
359
|
+
// Load product data
|
|
360
|
+
const product = useObjectLoader({
|
|
361
|
+
method: 'GET',
|
|
362
|
+
url: `/api/products/${productId}`,
|
|
363
|
+
getRequestOptions: useRequestOptions().auth
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// Form setup
|
|
367
|
+
const form = useForm({
|
|
368
|
+
validationSchema: toTypedSchema(
|
|
369
|
+
v.object({
|
|
370
|
+
name: v.pipe(v.string(), v.minLength(3)),
|
|
371
|
+
sku: v.pipe(v.string(), v.minLength(3)),
|
|
372
|
+
category: v.string(),
|
|
373
|
+
price: v.pipe(v.number(), v.minValue(0)),
|
|
374
|
+
stock: v.pipe(v.number(), v.minValue(0)),
|
|
375
|
+
status: v.string(),
|
|
376
|
+
description: v.optional(v.string(), '')
|
|
377
|
+
})
|
|
378
|
+
)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Load product and populate form
|
|
382
|
+
onMounted(async () => {
|
|
383
|
+
await product.run()
|
|
384
|
+
|
|
385
|
+
if (product.status.value.isSuccess && product.data.value) {
|
|
386
|
+
form.setValues(product.data.value)
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const handleImageChange = (event: Event) => {
|
|
391
|
+
const target = event.target as HTMLInputElement
|
|
392
|
+
const file = target.files?.[0]
|
|
393
|
+
|
|
394
|
+
if (file) {
|
|
395
|
+
imageFile.value = file
|
|
396
|
+
imagePreview.value = URL.createObjectURL(file)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
401
|
+
// Upload image first if changed
|
|
402
|
+
let imageUrl = product.data.value?.image_url
|
|
403
|
+
|
|
404
|
+
if (imageFile.value) {
|
|
405
|
+
const formData = new FormData()
|
|
406
|
+
formData.append('file', imageFile.value)
|
|
407
|
+
|
|
408
|
+
const uploadLoader = useObjectLoader({
|
|
409
|
+
method: 'POST',
|
|
410
|
+
url: '/api/upload',
|
|
411
|
+
getRequestOptions: useRequestOptions().file
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await uploadLoader.run({ data: formData })
|
|
415
|
+
|
|
416
|
+
if (uploadLoader.status.value.isSuccess) {
|
|
417
|
+
imageUrl = uploadLoader.data.value.url
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Update product
|
|
422
|
+
const updateLoader = useObjectLoader({
|
|
423
|
+
method: 'PUT',
|
|
424
|
+
url: `/api/products/${productId}`,
|
|
425
|
+
getRequestOptions: useRequestOptions().auth
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
await updateLoader.run({
|
|
429
|
+
data: {
|
|
430
|
+
...values,
|
|
431
|
+
image_url: imageUrl
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
if (updateLoader.status.value.isSuccess) {
|
|
436
|
+
navigateTo('/products')
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
const categoryOptions = [
|
|
441
|
+
{ label: 'Electronics', value: 'electronics' },
|
|
442
|
+
{ label: 'Clothing', value: 'clothing' },
|
|
443
|
+
{ label: 'Food', value: 'food' }
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
const statusOptions = [
|
|
447
|
+
{ label: 'Active', value: 'active' },
|
|
448
|
+
{ label: 'Inactive', value: 'inactive' }
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
const navItems = [
|
|
452
|
+
{ label: 'Products', to: '/products', icon: 'i-heroicons-shopping-bag' }
|
|
453
|
+
]
|
|
454
|
+
</script>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Dashboard Page
|
|
460
|
+
|
|
461
|
+
```vue
|
|
462
|
+
<!-- pages/dashboard.vue -->
|
|
463
|
+
<template>
|
|
464
|
+
<LayoutAdmin label="Dashboard" :items="navItems">
|
|
465
|
+
<div class="space-y-6">
|
|
466
|
+
<!-- Stats Cards -->
|
|
467
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
468
|
+
<Card v-for="stat in stats" :key="stat.label">
|
|
469
|
+
<div class="flex items-center justify-between">
|
|
470
|
+
<div>
|
|
471
|
+
<p class="text-sm text-gray-600">{{ stat.label }}</p>
|
|
472
|
+
<p class="text-2xl font-bold mt-1">{{ stat.value }}</p>
|
|
473
|
+
<p
|
|
474
|
+
:class="[
|
|
475
|
+
'text-sm mt-1',
|
|
476
|
+
stat.change >= 0 ? 'text-success' : 'text-error'
|
|
477
|
+
]"
|
|
478
|
+
>
|
|
479
|
+
{{ stat.change >= 0 ? '+' : '' }}{{ stat.change }}%
|
|
480
|
+
</p>
|
|
481
|
+
</div>
|
|
482
|
+
<div
|
|
483
|
+
:class="[
|
|
484
|
+
'w-12 h-12 rounded-full flex items-center justify-center',
|
|
485
|
+
stat.color
|
|
486
|
+
]"
|
|
487
|
+
>
|
|
488
|
+
<Icon :name="stat.icon" class="text-2xl" />
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
</Card>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<!-- Charts -->
|
|
495
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
496
|
+
<Card>
|
|
497
|
+
<h3 class="text-lg font-semibold mb-4">Revenue Overview</h3>
|
|
498
|
+
<!-- Add your chart component here -->
|
|
499
|
+
</Card>
|
|
500
|
+
|
|
501
|
+
<Card>
|
|
502
|
+
<h3 class="text-lg font-semibold mb-4">Top Products</h3>
|
|
503
|
+
<!-- Add your chart component here -->
|
|
504
|
+
</Card>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<!-- Recent Activities -->
|
|
508
|
+
<Card>
|
|
509
|
+
<h3 class="text-lg font-semibold mb-4">Recent Activities</h3>
|
|
510
|
+
<StatusBox :status="activities.status.value" :data="activities.data.value">
|
|
511
|
+
<template #default="{ data }">
|
|
512
|
+
<div class="space-y-3">
|
|
513
|
+
<div
|
|
514
|
+
v-for="activity in data"
|
|
515
|
+
:key="activity.id"
|
|
516
|
+
class="flex items-center gap-3 p-3 hover:bg-gray-50 rounded"
|
|
517
|
+
>
|
|
518
|
+
<Avatar :src="activity.user.avatar_url" />
|
|
519
|
+
<div class="flex-1">
|
|
520
|
+
<p class="font-medium">{{ activity.user.name }}</p>
|
|
521
|
+
<p class="text-sm text-gray-600">{{ activity.description }}</p>
|
|
522
|
+
</div>
|
|
523
|
+
<p class="text-sm text-gray-500">
|
|
524
|
+
{{ formatRelativeTime(activity.created_at) }}
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</template>
|
|
529
|
+
</StatusBox>
|
|
530
|
+
</Card>
|
|
531
|
+
</div>
|
|
532
|
+
</LayoutAdmin>
|
|
533
|
+
</template>
|
|
534
|
+
|
|
535
|
+
<script setup lang="ts">
|
|
536
|
+
definePageMeta({
|
|
537
|
+
middleware: ['auth']
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
const stats = ref([
|
|
541
|
+
{
|
|
542
|
+
label: 'Total Revenue',
|
|
543
|
+
value: '$45,231',
|
|
544
|
+
change: 12.5,
|
|
545
|
+
icon: 'ph:currency-dollar',
|
|
546
|
+
color: 'bg-success/10 text-success'
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
label: 'Total Orders',
|
|
550
|
+
value: '1,234',
|
|
551
|
+
change: 8.2,
|
|
552
|
+
icon: 'ph:shopping-cart',
|
|
553
|
+
color: 'bg-primary/10 text-primary'
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
label: 'Total Customers',
|
|
557
|
+
value: '567',
|
|
558
|
+
change: -2.4,
|
|
559
|
+
icon: 'ph:users',
|
|
560
|
+
color: 'bg-warning/10 text-warning'
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
label: 'Conversion Rate',
|
|
564
|
+
value: '3.2%',
|
|
565
|
+
change: 5.1,
|
|
566
|
+
icon: 'ph:chart-line',
|
|
567
|
+
color: 'bg-info/10 text-info'
|
|
568
|
+
}
|
|
569
|
+
])
|
|
570
|
+
|
|
571
|
+
const activities = useListLoader({
|
|
572
|
+
method: 'GET',
|
|
573
|
+
url: '/api/activities',
|
|
574
|
+
getRequestOptions: useRequestOptions().auth
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
onMounted(() => activities.run({ params: { limit: 10 } }))
|
|
578
|
+
|
|
579
|
+
const formatRelativeTime = (date: string) => {
|
|
580
|
+
// Implement relative time formatting
|
|
581
|
+
return new Date(date).toLocaleDateString()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const navItems = [
|
|
585
|
+
{ label: 'Dashboard', to: '/dashboard', icon: 'i-heroicons-home' }
|
|
586
|
+
]
|
|
587
|
+
</script>
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
**More examples available in [COMPONENT_EXAMPLES.md](./COMPONENT_EXAMPLES.md)**
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
**Last Updated:** 2025-11-07
|
|
597
|
+
|