@finema/finework-layer 0.2.50 → 0.2.52
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 +12 -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 +9 -13
- package/package.json +1 -1
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
# Component Examples & Usage Guide
|
|
2
|
+
|
|
3
|
+
## 📚 Table of Contents
|
|
4
|
+
|
|
5
|
+
1. [StatusBox Component](#statusbox-component)
|
|
6
|
+
2. [InfoItemList Component](#infoitemlist-component)
|
|
7
|
+
3. [Button Components](#button-components)
|
|
8
|
+
4. [Layout Components](#layout-components)
|
|
9
|
+
5. [Form Patterns](#form-patterns)
|
|
10
|
+
6. [Data Loading Patterns](#data-loading-patterns)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## StatusBox Component
|
|
15
|
+
|
|
16
|
+
### Basic Usage
|
|
17
|
+
|
|
18
|
+
```vue
|
|
19
|
+
<template>
|
|
20
|
+
<StatusBox :status="loader.status.value" :data="loader.data.value">
|
|
21
|
+
<template #default="{ data }">
|
|
22
|
+
<Card>
|
|
23
|
+
<h1>{{ data.title }}</h1>
|
|
24
|
+
<p>{{ data.description }}</p>
|
|
25
|
+
</Card>
|
|
26
|
+
</template>
|
|
27
|
+
</StatusBox>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
interface Project {
|
|
32
|
+
id: string
|
|
33
|
+
title: string
|
|
34
|
+
description: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const loader = useObjectLoader<Project>({
|
|
38
|
+
method: 'GET',
|
|
39
|
+
url: '/api/projects/123',
|
|
40
|
+
getRequestOptions: useRequestOptions().auth
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
onMounted(() => loader.run())
|
|
44
|
+
</script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### With Skip Condition
|
|
48
|
+
|
|
49
|
+
```vue
|
|
50
|
+
<template>
|
|
51
|
+
<StatusBox
|
|
52
|
+
:status="loader.status.value"
|
|
53
|
+
:data="loader.data.value"
|
|
54
|
+
:skip="!shouldLoad"
|
|
55
|
+
>
|
|
56
|
+
<template #default="{ data }">
|
|
57
|
+
<!-- Content -->
|
|
58
|
+
</template>
|
|
59
|
+
</StatusBox>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<script setup lang="ts">
|
|
63
|
+
const shouldLoad = ref(true)
|
|
64
|
+
</script>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### States Handled
|
|
68
|
+
|
|
69
|
+
1. **Loading State** - Shows spinner
|
|
70
|
+
2. **Error State** - Shows error message with icon
|
|
71
|
+
3. **Not Found State** - Shows "ไม่พบข้อมูล" message
|
|
72
|
+
4. **Success State** - Renders slot content
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## InfoItemList Component
|
|
77
|
+
|
|
78
|
+
### Basic Two-Column Layout
|
|
79
|
+
|
|
80
|
+
```vue
|
|
81
|
+
<template>
|
|
82
|
+
<InfoItemList
|
|
83
|
+
:items="[
|
|
84
|
+
{ label: 'ชื่อโครงการ', value: project.name },
|
|
85
|
+
{ label: 'รหัสโครงการ', value: project.code },
|
|
86
|
+
{ label: 'สถานะ', value: project.status },
|
|
87
|
+
{ label: 'วันที่เริ่มต้น', value: project.start_date, type: 'date' }
|
|
88
|
+
]"
|
|
89
|
+
/>
|
|
90
|
+
</template>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Vertical Layout
|
|
94
|
+
|
|
95
|
+
```vue
|
|
96
|
+
<template>
|
|
97
|
+
<InfoItemList
|
|
98
|
+
:items="items"
|
|
99
|
+
:vertical="true"
|
|
100
|
+
/>
|
|
101
|
+
</template>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Inline Layout
|
|
105
|
+
|
|
106
|
+
```vue
|
|
107
|
+
<template>
|
|
108
|
+
<InfoItemList
|
|
109
|
+
:items="[
|
|
110
|
+
{ label: 'Status:', value: 'Active' },
|
|
111
|
+
{ label: 'Count:', value: '42' }
|
|
112
|
+
]"
|
|
113
|
+
:inline="true"
|
|
114
|
+
/>
|
|
115
|
+
</template>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### With Text Truncation
|
|
119
|
+
|
|
120
|
+
```vue
|
|
121
|
+
<template>
|
|
122
|
+
<InfoItemList
|
|
123
|
+
:items="[
|
|
124
|
+
{
|
|
125
|
+
label: 'Description',
|
|
126
|
+
value: longDescription,
|
|
127
|
+
max: 200 // Truncate at 200 characters
|
|
128
|
+
}
|
|
129
|
+
]"
|
|
130
|
+
/>
|
|
131
|
+
</template>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### With Custom Components
|
|
135
|
+
|
|
136
|
+
```vue
|
|
137
|
+
<template>
|
|
138
|
+
<InfoItemList
|
|
139
|
+
:items="[
|
|
140
|
+
{
|
|
141
|
+
label: 'Status',
|
|
142
|
+
component: Badge,
|
|
143
|
+
props: {
|
|
144
|
+
label: project.status,
|
|
145
|
+
color: getStatusColor(project.status)
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: 'Actions',
|
|
150
|
+
component: Button,
|
|
151
|
+
props: {
|
|
152
|
+
label: 'Edit',
|
|
153
|
+
icon: 'ph:pencil-simple',
|
|
154
|
+
onClick: handleEdit
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
]"
|
|
158
|
+
/>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<script setup lang="ts">
|
|
162
|
+
import { Badge, Button } from '#components'
|
|
163
|
+
|
|
164
|
+
const getStatusColor = (status: string) => {
|
|
165
|
+
return status === 'active' ? 'success' : 'neutral'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const handleEdit = () => {
|
|
169
|
+
console.log('Edit clicked')
|
|
170
|
+
}
|
|
171
|
+
</script>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### With Slots
|
|
175
|
+
|
|
176
|
+
```vue
|
|
177
|
+
<template>
|
|
178
|
+
<InfoItemList :items="items">
|
|
179
|
+
<template #status-item="{ value }">
|
|
180
|
+
<Badge
|
|
181
|
+
:label="value"
|
|
182
|
+
:color="value === 'active' ? 'success' : 'error'"
|
|
183
|
+
/>
|
|
184
|
+
</template>
|
|
185
|
+
|
|
186
|
+
<template #actions-item>
|
|
187
|
+
<div class="flex gap-2">
|
|
188
|
+
<Button label="Edit" size="sm" />
|
|
189
|
+
<Button label="Delete" size="sm" color="error" />
|
|
190
|
+
</div>
|
|
191
|
+
</template>
|
|
192
|
+
</InfoItemList>
|
|
193
|
+
</template>
|
|
194
|
+
|
|
195
|
+
<script setup lang="ts">
|
|
196
|
+
const items = [
|
|
197
|
+
{ label: 'Name', value: 'John Doe' },
|
|
198
|
+
{ label: 'Status', value: 'active', key: 'status' },
|
|
199
|
+
{ label: 'Actions', key: 'actions' }
|
|
200
|
+
]
|
|
201
|
+
</script>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### All Item Types
|
|
205
|
+
|
|
206
|
+
```vue
|
|
207
|
+
<template>
|
|
208
|
+
<InfoItemList
|
|
209
|
+
:items="[
|
|
210
|
+
{ label: 'Text', value: 'Sample text', type: 'text' },
|
|
211
|
+
{ label: 'Number', value: 42, type: 'number' },
|
|
212
|
+
{ label: 'Currency', value: 1500.50, type: 'currency' },
|
|
213
|
+
{ label: 'Date', value: '2025-11-07', type: 'date' },
|
|
214
|
+
{ label: 'DateTime', value: '2025-11-07T10:30:00', type: 'date_time' },
|
|
215
|
+
{ label: 'Active', value: true, type: 'boolean' }
|
|
216
|
+
]"
|
|
217
|
+
/>
|
|
218
|
+
</template>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Conditional Display
|
|
222
|
+
|
|
223
|
+
```vue
|
|
224
|
+
<template>
|
|
225
|
+
<InfoItemList
|
|
226
|
+
:items="[
|
|
227
|
+
{ label: 'Name', value: user.name },
|
|
228
|
+
{ label: 'Email', value: user.email },
|
|
229
|
+
{
|
|
230
|
+
label: 'Admin Email',
|
|
231
|
+
value: user.admin_email,
|
|
232
|
+
hide: !user.is_admin // Conditionally hide
|
|
233
|
+
}
|
|
234
|
+
]"
|
|
235
|
+
/>
|
|
236
|
+
</template>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Button Components
|
|
242
|
+
|
|
243
|
+
### ActionIcon
|
|
244
|
+
|
|
245
|
+
```vue
|
|
246
|
+
<template>
|
|
247
|
+
<div class="flex gap-2">
|
|
248
|
+
<!-- Edit button -->
|
|
249
|
+
<ButtonActionIcon
|
|
250
|
+
icon="ph:pencil-simple"
|
|
251
|
+
color="primary"
|
|
252
|
+
@click="handleEdit"
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
<!-- Delete button -->
|
|
256
|
+
<ButtonActionIcon
|
|
257
|
+
icon="ph:trash"
|
|
258
|
+
color="error"
|
|
259
|
+
@click="handleDelete"
|
|
260
|
+
/>
|
|
261
|
+
|
|
262
|
+
<!-- View button with navigation -->
|
|
263
|
+
<ButtonActionIcon
|
|
264
|
+
icon="ph:eye"
|
|
265
|
+
color="neutral"
|
|
266
|
+
:to="`/view/${id}`"
|
|
267
|
+
/>
|
|
268
|
+
|
|
269
|
+
<!-- Loading state -->
|
|
270
|
+
<ButtonActionIcon
|
|
271
|
+
icon="ph:check"
|
|
272
|
+
color="success"
|
|
273
|
+
:loading="isSubmitting"
|
|
274
|
+
:disabled="isSubmitting"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
</template>
|
|
278
|
+
|
|
279
|
+
<script setup lang="ts">
|
|
280
|
+
const isSubmitting = ref(false)
|
|
281
|
+
|
|
282
|
+
const handleEdit = () => {
|
|
283
|
+
console.log('Edit')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handleDelete = async () => {
|
|
287
|
+
isSubmitting.value = true
|
|
288
|
+
await deleteItem()
|
|
289
|
+
isSubmitting.value = false
|
|
290
|
+
}
|
|
291
|
+
</script>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Back Button
|
|
295
|
+
|
|
296
|
+
```vue
|
|
297
|
+
<template>
|
|
298
|
+
<div class="space-y-4">
|
|
299
|
+
<!-- Default back button -->
|
|
300
|
+
<ButtonBack />
|
|
301
|
+
|
|
302
|
+
<!-- Custom label -->
|
|
303
|
+
<ButtonBack label="กลับไปหน้ารายการ" />
|
|
304
|
+
|
|
305
|
+
<!-- With route -->
|
|
306
|
+
<ButtonBack
|
|
307
|
+
label="กลับไปหน้าหลัก"
|
|
308
|
+
:to="routes.home.to"
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
<!-- With custom handler -->
|
|
312
|
+
<ButtonBack
|
|
313
|
+
label="ยกเลิก"
|
|
314
|
+
@click="handleCancel"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
</template>
|
|
318
|
+
|
|
319
|
+
<script setup lang="ts">
|
|
320
|
+
const handleCancel = () => {
|
|
321
|
+
// Custom logic before going back
|
|
322
|
+
if (confirm('Are you sure?')) {
|
|
323
|
+
navigateTo(-1)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
</script>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Layout Components
|
|
332
|
+
|
|
333
|
+
### Admin Layout
|
|
334
|
+
|
|
335
|
+
```vue
|
|
336
|
+
<template>
|
|
337
|
+
<LayoutAdmin
|
|
338
|
+
label="PMO Admin"
|
|
339
|
+
:items="navigationItems"
|
|
340
|
+
>
|
|
341
|
+
<!-- Page Header -->
|
|
342
|
+
<template #page-header>
|
|
343
|
+
<div class="flex items-center justify-between">
|
|
344
|
+
<h1 class="text-2xl font-bold">Dashboard</h1>
|
|
345
|
+
<Button label="New Project" icon="ph:plus" />
|
|
346
|
+
</div>
|
|
347
|
+
</template>
|
|
348
|
+
|
|
349
|
+
<!-- Main Content -->
|
|
350
|
+
<div class="space-y-6">
|
|
351
|
+
<Card>
|
|
352
|
+
<h2>Statistics</h2>
|
|
353
|
+
<!-- Content -->
|
|
354
|
+
</Card>
|
|
355
|
+
</div>
|
|
356
|
+
</LayoutAdmin>
|
|
357
|
+
</template>
|
|
358
|
+
|
|
359
|
+
<script setup lang="ts">
|
|
360
|
+
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
361
|
+
|
|
362
|
+
const navigationItems: NavigationMenuItem[] = [
|
|
363
|
+
{
|
|
364
|
+
label: 'Dashboard',
|
|
365
|
+
icon: 'i-heroicons-home',
|
|
366
|
+
to: '/admin/dashboard'
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
label: 'Projects',
|
|
370
|
+
icon: 'i-heroicons-folder',
|
|
371
|
+
children: [
|
|
372
|
+
{ label: 'All Projects', to: '/admin/projects' },
|
|
373
|
+
{ label: 'Active Projects', to: '/admin/projects/active' },
|
|
374
|
+
{ label: 'Archived', to: '/admin/projects/archived' }
|
|
375
|
+
]
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
label: 'Users',
|
|
379
|
+
icon: 'i-heroicons-users',
|
|
380
|
+
to: '/admin/users'
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
label: 'Settings',
|
|
384
|
+
icon: 'i-heroicons-cog',
|
|
385
|
+
to: '/admin/settings'
|
|
386
|
+
}
|
|
387
|
+
]
|
|
388
|
+
</script>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### User Layout
|
|
392
|
+
|
|
393
|
+
```vue
|
|
394
|
+
<template>
|
|
395
|
+
<LayoutUser>
|
|
396
|
+
<div class="container mx-auto px-4 py-8">
|
|
397
|
+
<h1>My Projects</h1>
|
|
398
|
+
|
|
399
|
+
<StatusBox :status="projects.status.value" :data="projects.data.value">
|
|
400
|
+
<template #default="{ data }">
|
|
401
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
402
|
+
<Card v-for="project in data" :key="project.id">
|
|
403
|
+
<h3>{{ project.name }}</h3>
|
|
404
|
+
<p>{{ project.description }}</p>
|
|
405
|
+
</Card>
|
|
406
|
+
</div>
|
|
407
|
+
</template>
|
|
408
|
+
</StatusBox>
|
|
409
|
+
</div>
|
|
410
|
+
</LayoutUser>
|
|
411
|
+
</template>
|
|
412
|
+
|
|
413
|
+
<script setup lang="ts">
|
|
414
|
+
const projects = useListLoader({
|
|
415
|
+
method: 'GET',
|
|
416
|
+
url: '/api/my-projects',
|
|
417
|
+
getRequestOptions: useRequestOptions().auth
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
onMounted(() => projects.run())
|
|
421
|
+
</script>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Form Patterns
|
|
427
|
+
|
|
428
|
+
### Basic Form with Validation
|
|
429
|
+
|
|
430
|
+
```vue
|
|
431
|
+
<template>
|
|
432
|
+
<form @submit="onSubmit">
|
|
433
|
+
<div class="space-y-4">
|
|
434
|
+
<FormField name="name" label="Project Name">
|
|
435
|
+
<Input v-model="form.values.name" />
|
|
436
|
+
<FormError name="name" />
|
|
437
|
+
</FormField>
|
|
438
|
+
|
|
439
|
+
<FormField name="description" label="Description">
|
|
440
|
+
<Textarea v-model="form.values.description" />
|
|
441
|
+
<FormError name="description" />
|
|
442
|
+
</FormField>
|
|
443
|
+
|
|
444
|
+
<FormField name="status" label="Status">
|
|
445
|
+
<Select
|
|
446
|
+
v-model="form.values.status"
|
|
447
|
+
:options="statusOptions"
|
|
448
|
+
/>
|
|
449
|
+
<FormError name="status" />
|
|
450
|
+
</FormField>
|
|
451
|
+
|
|
452
|
+
<div class="flex gap-2">
|
|
453
|
+
<Button
|
|
454
|
+
type="submit"
|
|
455
|
+
label="Save"
|
|
456
|
+
:loading="form.isSubmitting.value"
|
|
457
|
+
/>
|
|
458
|
+
<ButtonBack />
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</form>
|
|
462
|
+
</template>
|
|
463
|
+
|
|
464
|
+
<script setup lang="ts">
|
|
465
|
+
import * as v from 'valibot'
|
|
466
|
+
import { toTypedSchema } from '@vee-validate/valibot'
|
|
467
|
+
|
|
468
|
+
const form = useForm({
|
|
469
|
+
validationSchema: toTypedSchema(
|
|
470
|
+
v.object({
|
|
471
|
+
name: v.pipe(
|
|
472
|
+
v.string('Name is required'),
|
|
473
|
+
v.minLength(3, 'Name must be at least 3 characters')
|
|
474
|
+
),
|
|
475
|
+
description: v.optional(v.string(), ''),
|
|
476
|
+
status: v.pipe(
|
|
477
|
+
v.string('Status is required'),
|
|
478
|
+
v.picklist(['active', 'inactive', 'pending'])
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
const statusOptions = [
|
|
485
|
+
{ label: 'Active', value: 'active' },
|
|
486
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
487
|
+
{ label: 'Pending', value: 'pending' }
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
491
|
+
const loader = useObjectLoader({
|
|
492
|
+
method: 'POST',
|
|
493
|
+
url: '/api/projects',
|
|
494
|
+
getRequestOptions: useRequestOptions().auth
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
await loader.run({ data: values })
|
|
498
|
+
|
|
499
|
+
if (loader.status.value.isSuccess) {
|
|
500
|
+
navigateTo('/projects')
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
</script>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Form with Date Range
|
|
507
|
+
|
|
508
|
+
```vue
|
|
509
|
+
<template>
|
|
510
|
+
<form @submit="onSubmit">
|
|
511
|
+
<FormField name="date_range" label="Date Range">
|
|
512
|
+
<DatePicker
|
|
513
|
+
v-model="form.values.date_range"
|
|
514
|
+
range
|
|
515
|
+
/>
|
|
516
|
+
<FormError name="date_range" />
|
|
517
|
+
</FormField>
|
|
518
|
+
</form>
|
|
519
|
+
</template>
|
|
520
|
+
|
|
521
|
+
<script setup lang="ts">
|
|
522
|
+
import * as v from 'valibot'
|
|
523
|
+
import { toTypedSchema } from '@vee-validate/valibot'
|
|
524
|
+
|
|
525
|
+
const form = useForm({
|
|
526
|
+
validationSchema: toTypedSchema(
|
|
527
|
+
v.object({
|
|
528
|
+
date_range: v.nullish(
|
|
529
|
+
v.object({
|
|
530
|
+
start: v.union([v.date(), v.string()]),
|
|
531
|
+
end: v.union([v.date(), v.string()])
|
|
532
|
+
})
|
|
533
|
+
)
|
|
534
|
+
})
|
|
535
|
+
)
|
|
536
|
+
})
|
|
537
|
+
</script>
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Data Loading Patterns
|
|
543
|
+
|
|
544
|
+
### Single Object Loading
|
|
545
|
+
|
|
546
|
+
```vue
|
|
547
|
+
<template>
|
|
548
|
+
<StatusBox :status="project.status.value" :data="project.data.value">
|
|
549
|
+
<template #default="{ data }">
|
|
550
|
+
<Card>
|
|
551
|
+
<InfoItemList
|
|
552
|
+
:items="[
|
|
553
|
+
{ label: 'Name', value: data.name },
|
|
554
|
+
{ label: 'Code', value: data.code },
|
|
555
|
+
{ label: 'Status', value: data.status }
|
|
556
|
+
]"
|
|
557
|
+
/>
|
|
558
|
+
</Card>
|
|
559
|
+
</template>
|
|
560
|
+
</StatusBox>
|
|
561
|
+
</template>
|
|
562
|
+
|
|
563
|
+
<script setup lang="ts">
|
|
564
|
+
interface Project {
|
|
565
|
+
id: string
|
|
566
|
+
name: string
|
|
567
|
+
code: string
|
|
568
|
+
status: string
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const route = useRoute()
|
|
572
|
+
const projectId = route.params.id as string
|
|
573
|
+
|
|
574
|
+
const project = useObjectLoader<Project>({
|
|
575
|
+
method: 'GET',
|
|
576
|
+
url: `/api/projects/${projectId}`,
|
|
577
|
+
getRequestOptions: useRequestOptions().auth
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
onMounted(() => project.run())
|
|
581
|
+
</script>
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### List Loading with Pagination
|
|
585
|
+
|
|
586
|
+
```vue
|
|
587
|
+
<template>
|
|
588
|
+
<div class="space-y-4">
|
|
589
|
+
<!-- Filters -->
|
|
590
|
+
<Card>
|
|
591
|
+
<form @submit.prevent="handleFilter">
|
|
592
|
+
<div class="flex gap-2">
|
|
593
|
+
<Input
|
|
594
|
+
v-model="filters.q"
|
|
595
|
+
placeholder="Search..."
|
|
596
|
+
/>
|
|
597
|
+
<Button type="submit" label="Search" />
|
|
598
|
+
</div>
|
|
599
|
+
</form>
|
|
600
|
+
</Card>
|
|
601
|
+
|
|
602
|
+
<!-- List -->
|
|
603
|
+
<StatusBox :status="projects.status.value" :data="projects.data.value">
|
|
604
|
+
<template #default="{ data }">
|
|
605
|
+
<Card>
|
|
606
|
+
<Table :rows="data.items" :columns="columns" />
|
|
607
|
+
|
|
608
|
+
<Pagination
|
|
609
|
+
v-model="page"
|
|
610
|
+
:total="data.total"
|
|
611
|
+
:per-page="perPage"
|
|
612
|
+
@update:model-value="loadProjects"
|
|
613
|
+
/>
|
|
614
|
+
</Card>
|
|
615
|
+
</template>
|
|
616
|
+
</StatusBox>
|
|
617
|
+
</div>
|
|
618
|
+
</template>
|
|
619
|
+
|
|
620
|
+
<script setup lang="ts">
|
|
621
|
+
const page = ref(1)
|
|
622
|
+
const perPage = ref(10)
|
|
623
|
+
const filters = reactive({
|
|
624
|
+
q: '',
|
|
625
|
+
status: ''
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
const projects = useListLoader({
|
|
629
|
+
method: 'GET',
|
|
630
|
+
url: '/api/projects',
|
|
631
|
+
getRequestOptions: useRequestOptions().auth
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const loadProjects = () => {
|
|
635
|
+
projects.run({
|
|
636
|
+
params: {
|
|
637
|
+
page: page.value,
|
|
638
|
+
per_page: perPage.value,
|
|
639
|
+
...filters
|
|
640
|
+
}
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const handleFilter = () => {
|
|
645
|
+
page.value = 1
|
|
646
|
+
loadProjects()
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
onMounted(() => loadProjects())
|
|
650
|
+
|
|
651
|
+
const columns = [
|
|
652
|
+
{ key: 'name', label: 'Name' },
|
|
653
|
+
{ key: 'code', label: 'Code' },
|
|
654
|
+
{ key: 'status', label: 'Status' }
|
|
655
|
+
]
|
|
656
|
+
</script>
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### Multiple Parallel Requests
|
|
660
|
+
|
|
661
|
+
```vue
|
|
662
|
+
<template>
|
|
663
|
+
<div class="space-y-4">
|
|
664
|
+
<StatusBox :status="user.status.value" :data="user.data.value">
|
|
665
|
+
<template #default="{ data: userData }">
|
|
666
|
+
<Card>
|
|
667
|
+
<h2>User: {{ userData.name }}</h2>
|
|
668
|
+
</Card>
|
|
669
|
+
</template>
|
|
670
|
+
</StatusBox>
|
|
671
|
+
|
|
672
|
+
<StatusBox :status="projects.status.value" :data="projects.data.value">
|
|
673
|
+
<template #default="{ data: projectsData }">
|
|
674
|
+
<Card>
|
|
675
|
+
<h2>Projects ({{ projectsData.length }})</h2>
|
|
676
|
+
</Card>
|
|
677
|
+
</template>
|
|
678
|
+
</StatusBox>
|
|
679
|
+
</div>
|
|
680
|
+
</template>
|
|
681
|
+
|
|
682
|
+
<script setup lang="ts">
|
|
683
|
+
const user = useObjectLoader({
|
|
684
|
+
method: 'GET',
|
|
685
|
+
url: '/api/user/123',
|
|
686
|
+
getRequestOptions: useRequestOptions().auth
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
const projects = useListLoader({
|
|
690
|
+
method: 'GET',
|
|
691
|
+
url: '/api/user/123/projects',
|
|
692
|
+
getRequestOptions: useRequestOptions().auth
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
onMounted(async () => {
|
|
696
|
+
// Load in parallel
|
|
697
|
+
await Promise.all([
|
|
698
|
+
user.run(),
|
|
699
|
+
projects.run()
|
|
700
|
+
])
|
|
701
|
+
})
|
|
702
|
+
</script>
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### Dependent Requests
|
|
706
|
+
|
|
707
|
+
```vue
|
|
708
|
+
<script setup lang="ts">
|
|
709
|
+
const user = useObjectLoader({
|
|
710
|
+
method: 'GET',
|
|
711
|
+
url: '/api/user/123',
|
|
712
|
+
getRequestOptions: useRequestOptions().auth
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
const projects = useListLoader({
|
|
716
|
+
method: 'GET',
|
|
717
|
+
url: computed(() => `/api/teams/${user.data.value?.team_id}/projects`),
|
|
718
|
+
getRequestOptions: useRequestOptions().auth
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
onMounted(async () => {
|
|
722
|
+
// Load user first
|
|
723
|
+
await user.run()
|
|
724
|
+
|
|
725
|
+
// Then load projects based on user's team
|
|
726
|
+
if (user.status.value.isSuccess && user.data.value?.team_id) {
|
|
727
|
+
await projects.run()
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
</script>
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## Complete Page Example
|
|
736
|
+
|
|
737
|
+
```vue
|
|
738
|
+
<template>
|
|
739
|
+
<LayoutAdmin label="Projects" :items="navItems">
|
|
740
|
+
<div class="space-y-6">
|
|
741
|
+
<!-- Page Header -->
|
|
742
|
+
<div class="flex items-center justify-between">
|
|
743
|
+
<div>
|
|
744
|
+
<h1 class="text-2xl font-bold">Projects</h1>
|
|
745
|
+
<p class="text-gray-600">Manage your projects</p>
|
|
746
|
+
</div>
|
|
747
|
+
<Button
|
|
748
|
+
label="New Project"
|
|
749
|
+
icon="ph:plus"
|
|
750
|
+
:to="routes.pmo.project.new.to"
|
|
751
|
+
/>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<!-- Filters -->
|
|
755
|
+
<Card>
|
|
756
|
+
<form @submit.prevent="handleFilter">
|
|
757
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
758
|
+
<FormField name="q" label="Search">
|
|
759
|
+
<Input v-model="form.values.q" placeholder="Search..." />
|
|
760
|
+
</FormField>
|
|
761
|
+
|
|
762
|
+
<FormField name="status" label="Status">
|
|
763
|
+
<Select
|
|
764
|
+
v-model="form.values.status"
|
|
765
|
+
:options="statusOptions"
|
|
766
|
+
/>
|
|
767
|
+
</FormField>
|
|
768
|
+
|
|
769
|
+
<div class="flex items-end gap-2">
|
|
770
|
+
<Button type="submit" label="Filter" />
|
|
771
|
+
<Button
|
|
772
|
+
type="button"
|
|
773
|
+
label="Reset"
|
|
774
|
+
variant="outline"
|
|
775
|
+
@click="handleReset"
|
|
776
|
+
/>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
</form>
|
|
780
|
+
</Card>
|
|
781
|
+
|
|
782
|
+
<!-- Projects List -->
|
|
783
|
+
<StatusBox :status="projects.status.value" :data="projects.data.value">
|
|
784
|
+
<template #default="{ data }">
|
|
785
|
+
<Card>
|
|
786
|
+
<Table
|
|
787
|
+
:rows="data.items"
|
|
788
|
+
:columns="columns"
|
|
789
|
+
>
|
|
790
|
+
<template #actions="{ row }">
|
|
791
|
+
<div class="flex gap-2">
|
|
792
|
+
<ButtonActionIcon
|
|
793
|
+
icon="ph:eye"
|
|
794
|
+
:to="`/projects/${row.id}`"
|
|
795
|
+
/>
|
|
796
|
+
<ButtonActionIcon
|
|
797
|
+
icon="ph:pencil-simple"
|
|
798
|
+
color="primary"
|
|
799
|
+
:to="`/projects/${row.id}/edit`"
|
|
800
|
+
/>
|
|
801
|
+
</div>
|
|
802
|
+
</template>
|
|
803
|
+
</Table>
|
|
804
|
+
|
|
805
|
+
<Pagination
|
|
806
|
+
v-model="page"
|
|
807
|
+
:total="data.total"
|
|
808
|
+
:per-page="perPage"
|
|
809
|
+
/>
|
|
810
|
+
</Card>
|
|
811
|
+
</template>
|
|
812
|
+
</StatusBox>
|
|
813
|
+
</div>
|
|
814
|
+
</LayoutAdmin>
|
|
815
|
+
</template>
|
|
816
|
+
|
|
817
|
+
<script setup lang="ts">
|
|
818
|
+
import * as v from 'valibot'
|
|
819
|
+
import { toTypedSchema } from '@vee-validate/valibot'
|
|
820
|
+
|
|
821
|
+
definePageMeta({
|
|
822
|
+
middleware: ['auth', 'permissions'],
|
|
823
|
+
accessGuard: {
|
|
824
|
+
permissions: ['pmo:USER', 'pmo:ADMIN']
|
|
825
|
+
}
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
const page = ref(1)
|
|
829
|
+
const perPage = ref(10)
|
|
830
|
+
|
|
831
|
+
const form = useForm({
|
|
832
|
+
validationSchema: toTypedSchema(
|
|
833
|
+
v.object({
|
|
834
|
+
q: v.optional(v.string(), ''),
|
|
835
|
+
status: v.optional(v.string(), '')
|
|
836
|
+
})
|
|
837
|
+
)
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
const projects = useListLoader({
|
|
841
|
+
method: 'GET',
|
|
842
|
+
url: '/api/projects',
|
|
843
|
+
getRequestOptions: useRequestOptions().auth
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
const loadProjects = () => {
|
|
847
|
+
projects.run({
|
|
848
|
+
params: {
|
|
849
|
+
page: page.value,
|
|
850
|
+
per_page: perPage.value,
|
|
851
|
+
...form.values
|
|
852
|
+
}
|
|
853
|
+
})
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const handleFilter = () => {
|
|
857
|
+
page.value = 1
|
|
858
|
+
loadProjects()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const handleReset = () => {
|
|
862
|
+
form.resetForm()
|
|
863
|
+
handleFilter()
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
watch(page, () => loadProjects())
|
|
867
|
+
|
|
868
|
+
onMounted(() => loadProjects())
|
|
869
|
+
|
|
870
|
+
const columns = [
|
|
871
|
+
{ key: 'name', label: 'Name' },
|
|
872
|
+
{ key: 'code', label: 'Code' },
|
|
873
|
+
{ key: 'status', label: 'Status' },
|
|
874
|
+
{ key: 'actions', label: 'Actions' }
|
|
875
|
+
]
|
|
876
|
+
|
|
877
|
+
const statusOptions = [
|
|
878
|
+
{ label: 'All', value: '' },
|
|
879
|
+
{ label: 'Active', value: 'active' },
|
|
880
|
+
{ label: 'Inactive', value: 'inactive' }
|
|
881
|
+
]
|
|
882
|
+
|
|
883
|
+
const navItems = [
|
|
884
|
+
{ label: 'Dashboard', to: '/admin', icon: 'i-heroicons-home' },
|
|
885
|
+
{ label: 'Projects', to: '/admin/projects', icon: 'i-heroicons-folder' }
|
|
886
|
+
]
|
|
887
|
+
</script>
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
**Last Updated:** 2025-11-07
|
|
893
|
+
|