@awes-io/ui 2.142.3 → 2.144.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/assets/css/components/_index.css +7 -1
- package/assets/css/components/action-card.css +1 -0
- package/assets/css/components/action-icon.css +2 -2
- package/assets/css/components/alert.css +28 -22
- package/assets/css/components/animation.css +52 -32
- package/assets/css/components/badge.css +1 -0
- package/assets/css/components/banner-text.css +15 -4
- package/assets/css/components/card.css +0 -1
- package/assets/css/components/content-placeholder.css +104 -0
- package/assets/css/components/dropdown.css +20 -7
- package/assets/css/components/empty-container.css +69 -1
- package/assets/css/components/filter-chosen.css +6 -0
- package/assets/css/components/filter-date-range.css +17 -1
- package/assets/css/components/filter-month.css +23 -17
- package/assets/css/components/filter-select.css +11 -0
- package/assets/css/components/icon-menu-item.css +12 -7
- package/assets/css/components/layout.css +1 -32
- package/assets/css/components/mobile-menu-nav.css +8 -4
- package/assets/css/components/modal.css +1 -1
- package/assets/css/components/number.css +12 -0
- package/assets/css/components/page-aside.css +54 -0
- package/assets/css/components/text-field.css +4 -0
- package/assets/js/css.js +1 -1
- package/assets/js/icons/mono.js +59 -91
- package/assets/js/icons/multicolor.js +1 -31
- package/components/1_atoms/AwActionIcon.vue +11 -2
- package/components/1_atoms/AwContentPlaceholder.vue +60 -0
- package/components/1_atoms/AwFlow.vue +37 -49
- package/components/1_atoms/AwGrid.vue +11 -3
- package/components/1_atoms/AwIcon/AwIcon.vue +5 -3
- package/components/1_atoms/AwIcon/AwIconSystemMono.vue +3 -2
- package/components/1_atoms/AwInput.vue +2 -2
- package/components/1_atoms/AwLabel.vue +1 -1
- package/components/1_atoms/AwList.vue +3 -1
- package/components/1_atoms/AwRadio.vue +1 -1
- package/components/1_atoms/AwSlider.vue +15 -1
- package/components/1_atoms/AwTag.vue +6 -1
- package/components/2_molecules/AwAlert.vue +63 -42
- package/components/2_molecules/AwBadge.vue +1 -1
- package/components/2_molecules/AwBannerText.vue +8 -2
- package/components/2_molecules/AwButton.vue +1 -1
- package/components/2_molecules/AwDescriptionInput.vue +19 -1
- package/components/2_molecules/AwEmptyContainer.vue +74 -72
- package/components/2_molecules/AwNumber.vue +180 -0
- package/components/2_molecules/AwSelect.vue +11 -4
- package/components/3_organisms/AwBottomBar.vue +22 -4
- package/components/3_organisms/AwFilterChosen.vue +73 -0
- package/components/3_organisms/AwFilterDateRange.vue +177 -0
- package/components/3_organisms/AwFilterMonth.vue +37 -40
- package/components/3_organisms/AwFilterSelect.vue +368 -0
- package/components/3_organisms/AwMultiBlockBuilder.vue +1 -1
- package/components/3_organisms/AwSubnav.vue +11 -1
- package/components/3_organisms/AwTable/AwTableBuilder.vue +20 -60
- package/components/3_organisms/AwTable/_AwTableCellDropdown.vue +6 -1
- package/components/3_organisms/AwTable/_AwTableRow.vue +2 -1
- package/components/4_pages/AwPage.vue +1 -0
- package/components/4_pages/AwPageAside.vue +108 -0
- package/components/5_layouts/AwLayoutCenter.vue +3 -8
- package/components/5_layouts/_AwMenuItemIcon.vue +9 -2
- package/components/5_layouts/_AwMobileMenuItem.vue +5 -3
- package/components/5_layouts/_AwUserMenu.vue +1 -1
- package/components/_config.js +26 -1
- package/docs/_template.md +80 -0
- package/docs/components/atoms/aw-accordion-fold.md +129 -0
- package/docs/components/atoms/aw-action-card-body.md +99 -0
- package/docs/components/atoms/aw-action-card.md +130 -0
- package/docs/components/atoms/aw-action-icon.md +126 -0
- package/docs/components/atoms/aw-avatar.md +106 -0
- package/docs/components/atoms/aw-card.md +137 -0
- package/docs/components/atoms/aw-checkbox.md +288 -0
- package/docs/components/atoms/aw-content-placeholder.md +147 -0
- package/docs/components/atoms/aw-description.md +83 -0
- package/docs/components/atoms/aw-dock.md +90 -0
- package/docs/components/atoms/aw-dropdown-button.md +94 -0
- package/docs/components/atoms/aw-dropdown.md +178 -0
- package/docs/components/atoms/aw-file.md +73 -0
- package/docs/components/atoms/aw-flow.md +140 -0
- package/docs/components/atoms/aw-grid.md +109 -0
- package/docs/components/atoms/aw-headline.md +71 -0
- package/docs/components/atoms/aw-icon-system-color.md +122 -0
- package/docs/components/atoms/aw-icon-system-mono.md +206 -0
- package/docs/components/atoms/aw-icon.md +235 -0
- package/docs/components/atoms/aw-info.md +123 -0
- package/docs/components/atoms/aw-input.md +212 -0
- package/docs/components/atoms/aw-label.md +136 -0
- package/docs/components/atoms/aw-link.md +151 -0
- package/docs/components/atoms/aw-list.md +152 -0
- package/docs/components/atoms/aw-progress.md +119 -0
- package/docs/components/atoms/aw-radio.md +182 -0
- package/docs/components/atoms/aw-refresh-wrapper.md +81 -0
- package/docs/components/atoms/aw-select-native.md +234 -0
- package/docs/components/atoms/aw-slider.md +189 -0
- package/docs/components/atoms/aw-sub-headline.md +73 -0
- package/docs/components/atoms/aw-switcher.md +192 -0
- package/docs/components/atoms/aw-tag.md +144 -0
- package/docs/components/atoms/aw-title.md +70 -0
- package/docs/components/atoms/aw-toggler.md +90 -0
- package/docs/components/layouts/aw-layout-center.md +168 -0
- package/docs/components/layouts/aw-layout-error.md +153 -0
- package/docs/components/layouts/aw-layout-provider.md +238 -0
- package/docs/components/layouts/aw-layout.md +88 -0
- package/docs/components/molecules/aw-action-button.md +138 -0
- package/docs/components/molecules/aw-alert.md +191 -0
- package/docs/components/molecules/aw-badge.md +129 -0
- package/docs/components/molecules/aw-banner-text.md +156 -0
- package/docs/components/molecules/aw-button-nav.md +111 -0
- package/docs/components/molecules/aw-button.md +193 -0
- package/docs/components/molecules/aw-description-input.md +124 -0
- package/docs/components/molecules/aw-empty-container.md +235 -0
- package/docs/components/molecules/aw-island.md +506 -0
- package/docs/components/molecules/aw-number.md +138 -0
- package/docs/components/molecules/aw-select-object.md +401 -0
- package/docs/components/molecules/aw-select.md +215 -0
- package/docs/components/molecules/aw-tab-nav.md +108 -0
- package/docs/components/molecules/aw-tel.md +129 -0
- package/docs/components/molecules/aw-textarea.md +83 -0
- package/docs/components/molecules/aw-userpic.md +115 -0
- package/docs/components/organisms/aw-address-block.md +64 -0
- package/docs/components/organisms/aw-address.md +132 -0
- package/docs/components/organisms/aw-birthday-picker.md +73 -0
- package/docs/components/organisms/aw-bottom-bar.md +66 -0
- package/docs/components/organisms/aw-calendar-days.md +115 -0
- package/docs/components/organisms/aw-calendar-nav.md +98 -0
- package/docs/components/organisms/aw-calendar-view.md +98 -0
- package/docs/components/organisms/aw-calendar.md +166 -0
- package/docs/components/organisms/aw-chart.md +154 -0
- package/docs/components/organisms/aw-chip-select.md +164 -0
- package/docs/components/organisms/aw-chip.md +126 -0
- package/docs/components/organisms/aw-code-snippet.md +94 -0
- package/docs/components/organisms/aw-code.md +132 -0
- package/docs/components/organisms/aw-context-menu.md +117 -0
- package/docs/components/organisms/aw-cropper.md +151 -0
- package/docs/components/organisms/aw-date.md +161 -0
- package/docs/components/organisms/aw-display-date.md +33 -0
- package/docs/components/organisms/aw-download-link.md +46 -0
- package/docs/components/organisms/aw-fetch-data.md +161 -0
- package/docs/components/organisms/aw-filter-chosen.md +226 -0
- package/docs/components/organisms/aw-filter-date-range.md +205 -0
- package/docs/components/organisms/aw-filter-month.md +43 -0
- package/docs/components/organisms/aw-filter-select.md +239 -0
- package/docs/components/organisms/aw-form.md +174 -0
- package/docs/components/organisms/aw-gmap-marker.md +86 -0
- package/docs/components/organisms/aw-gmap.md +90 -0
- package/docs/components/organisms/aw-image-upload.md +56 -0
- package/docs/components/organisms/aw-island-avatar.md +87 -0
- package/docs/components/organisms/aw-markdown-editor.md +104 -0
- package/docs/components/organisms/aw-modal-buttons.md +57 -0
- package/docs/components/organisms/aw-modal.md +246 -0
- package/docs/components/organisms/aw-model-edit.md +74 -0
- package/docs/components/organisms/aw-money.md +53 -0
- package/docs/components/organisms/aw-multi-block-builder.md +165 -0
- package/docs/components/organisms/aw-pagination.md +121 -0
- package/docs/components/organisms/aw-password.md +103 -0
- package/docs/components/organisms/aw-preview-card.md +45 -0
- package/docs/components/organisms/aw-search.md +116 -0
- package/docs/components/organisms/aw-subnav.md +122 -0
- package/docs/components/organisms/aw-table-builder.md +165 -0
- package/docs/components/organisms/aw-table-col.md +123 -0
- package/docs/components/organisms/aw-table-head.md +92 -0
- package/docs/components/organisms/aw-table-row.md +91 -0
- package/docs/components/organisms/aw-table.md +172 -0
- package/docs/components/organisms/aw-tags.md +54 -0
- package/docs/components/organisms/aw-toggle-show-aside.md +43 -0
- package/docs/components/organisms/aw-uploader-files.md +125 -0
- package/docs/components/organisms/aw-uploader.md +163 -0
- package/docs/components/organisms/aw-user-menu.md +87 -0
- package/docs/components/pages/aw-page-aside.md +296 -0
- package/docs/components/pages/aw-page-menu-buttons.md +172 -0
- package/docs/components/pages/aw-page-modal.md +198 -0
- package/docs/components/pages/aw-page-single.md +300 -0
- package/docs/components/pages/aw-page.md +194 -0
- package/docs/configuration.md +493 -0
- package/docs/cookbook/advanced-patterns.md +1388 -0
- package/docs/cookbook/common-patterns.md +965 -0
- package/docs/cookbook/index.md +786 -0
- package/docs/getting-started.md +596 -0
- package/docs/guides/best-practices.md +1106 -0
- package/docs/guides/data-fetching.md +852 -0
- package/docs/guides/error-handling.md +1172 -0
- package/docs/guides/forms-guide.md +1329 -0
- package/docs/guides/mobile-subnavigation.md +359 -0
- package/docs/guides/page-patterns/aside-pages.md +1418 -0
- package/docs/guides/page-patterns/dashboard-pages.md +990 -0
- package/docs/guides/page-patterns/detail-pages.md +1556 -0
- package/docs/guides/page-patterns/list-pages.md +1242 -0
- package/docs/index.md +263 -1
- package/docs/integrations.md +870 -0
- package/docs/reference/colors.md +232 -0
- package/docs/reference/icons.md +163 -0
- package/docs/reference/menu.md +462 -0
- package/docs/reference/plugins.md +970 -0
- package/docs/reference/troubleshooting.md +964 -0
- package/nuxt/awes.config.js +9 -8
- package/nuxt/index.js +2 -2
- package/nuxt/pages/more.vue +1 -1
- package/package.json +5 -3
- package/readme.md +171 -1
- package/store/awesIo.js +11 -0
- package/CHANGELOG.md +0 -4520
- package/docs/aw-accordion-fold.md +0 -46
- package/docs/aw-address.md +0 -44
- package/docs/aw-avatar.md +0 -51
- package/docs/aw-badge.md +0 -32
- package/docs/aw-button-nav.md +0 -44
- package/docs/aw-button.md +0 -50
- package/docs/aw-calendar-days.md +0 -46
- package/docs/aw-calendar-nav.md +0 -25
- package/docs/aw-calendar-view.md +0 -12
- package/docs/aw-calendar.md +0 -59
- package/docs/aw-card.md +0 -48
- package/docs/aw-chart.md +0 -51
- package/docs/aw-checkbox.md +0 -56
- package/docs/aw-chip-select.md +0 -46
- package/docs/aw-chip.md +0 -53
- package/docs/aw-code-snippet.md +0 -18
- package/docs/aw-code.md +0 -56
- package/docs/aw-content-wrapper.md +0 -40
- package/docs/aw-context-menu.md +0 -31
- package/docs/aw-cropper.md +0 -60
- package/docs/aw-dashboard-card.md +0 -37
- package/docs/aw-dashboard-donut.md +0 -30
- package/docs/aw-dashboard-line.md +0 -20
- package/docs/aw-dashboard-progress.md +0 -33
- package/docs/aw-dashboard-section.md +0 -32
- package/docs/aw-dashboard-speed.md +0 -30
- package/docs/aw-date.md +0 -52
- package/docs/aw-dropdown-button.md +0 -31
- package/docs/aw-dropdown.md +0 -69
- package/docs/aw-fetch-data.md +0 -45
- package/docs/aw-form.md +0 -52
- package/docs/aw-grid.md +0 -48
- package/docs/aw-icon.md +0 -50
- package/docs/aw-info.md +0 -53
- package/docs/aw-input.md +0 -55
- package/docs/aw-layout-default.md +0 -30
- package/docs/aw-layout-frame-center.md +0 -29
- package/docs/aw-layout-simple.md +0 -49
- package/docs/aw-link.md +0 -54
- package/docs/aw-markdown-editor.md +0 -51
- package/docs/aw-modal.md +0 -63
- package/docs/aw-multi-block-builder.md +0 -66
- package/docs/aw-page.md +0 -36
- package/docs/aw-pagination.md +0 -54
- package/docs/aw-password.md +0 -48
- package/docs/aw-radio.md +0 -54
- package/docs/aw-search.md +0 -49
- package/docs/aw-select.md +0 -93
- package/docs/aw-slider.md +0 -40
- package/docs/aw-svg-image.md +0 -19
- package/docs/aw-switcher.md +0 -51
- package/docs/aw-tab-nav.md +0 -55
- package/docs/aw-table-builder.md +0 -58
- package/docs/aw-table-col.md +0 -33
- package/docs/aw-table-head.md +0 -28
- package/docs/aw-table-row.md +0 -33
- package/docs/aw-table.md +0 -59
- package/docs/aw-tel.md +0 -47
- package/docs/aw-textarea.md +0 -47
- package/docs/aw-toggler.md +0 -41
- package/docs/aw-uploader-files.md +0 -20
- package/docs/aw-uploader.md +0 -60
- package/docs/aw-user-menu.md +0 -34
- package/docs/aw-userpic.md +0 -34
- /package/components/{3_organisms → 2_molecules}/AwTel.vue +0 -0
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
# Detail Page Patterns
|
|
2
|
+
|
|
3
|
+
Complete guide to building create, edit, and view pages using AwPageSingle with forms and validation.
|
|
4
|
+
|
|
5
|
+
## When to Use AwPageSingle
|
|
6
|
+
|
|
7
|
+
Use `AwPageSingle` for:
|
|
8
|
+
- **Create pages** - Forms for creating new records
|
|
9
|
+
- **Edit pages** - Forms for updating existing records
|
|
10
|
+
- **Detail views** - Display single item details
|
|
11
|
+
- **Single-focus workflows** - Wizards, settings, profiles
|
|
12
|
+
|
|
13
|
+
**Key characteristics:**
|
|
14
|
+
- Can hide aside menu with `hide-menu` prop
|
|
15
|
+
- Full-width content area
|
|
16
|
+
- Action button in header (save, submit, etc.)
|
|
17
|
+
- Best for focused tasks and forms
|
|
18
|
+
|
|
19
|
+
**⚠️ Essential Requirements:**
|
|
20
|
+
|
|
21
|
+
1. **Always use `layout: 'empty'`**
|
|
22
|
+
|
|
23
|
+
Every page using `AwPageSingle` must include `layout: 'empty'` in the component export:
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
export default {
|
|
27
|
+
layout: 'empty', // Required for AwPageSingle pages
|
|
28
|
+
// ... rest of component
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
2. **Always use vue-mc models for data fetching and creation**
|
|
33
|
+
|
|
34
|
+
Items fetching, creation, and updates MUST use vue-mc models (`BaseModel` from `@awes-io/vue-mc`). Do not use custom `fetch()` API calls or manual HTTP requests.
|
|
35
|
+
|
|
36
|
+
```javascript
|
|
37
|
+
// ✅ GOOD - Use vue-mc model
|
|
38
|
+
import { BaseModel } from '@awes-io/vue-mc'
|
|
39
|
+
|
|
40
|
+
class Service extends BaseModel {
|
|
41
|
+
defaults() {
|
|
42
|
+
return {
|
|
43
|
+
id: null,
|
|
44
|
+
name: '',
|
|
45
|
+
description: ''
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
routes() {
|
|
50
|
+
return {
|
|
51
|
+
fetch: '/api/services/{id}',
|
|
52
|
+
save: '/api/services',
|
|
53
|
+
update: '/api/services/{id}',
|
|
54
|
+
delete: '/api/services/{id}'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default {
|
|
60
|
+
data() {
|
|
61
|
+
return {
|
|
62
|
+
service: new Service()
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
methods: {
|
|
66
|
+
async save() {
|
|
67
|
+
await this.service.save() // Uses vue-mc
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ❌ BAD - Don't use custom fetch
|
|
73
|
+
async save() {
|
|
74
|
+
const res = await fetch('/api/services', { ... }) // Wrong!
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
3. **CTA button MUST use `:action` prop, not `#buttons` slot**
|
|
79
|
+
|
|
80
|
+
The primary action button (Save, Create, etc.) MUST use the `:action` prop and `@action` event on `AwPageSingle`. The `#buttons` slot is ONLY for secondary actions (Delete, Disable, etc.) wrapped in `AwPageMenuButtons`.
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
// ✅ GOOD - CTA button via :action prop
|
|
84
|
+
<AwPageSingle
|
|
85
|
+
:action="{ text: 'Save', loading: model.saving }"
|
|
86
|
+
@action="save"
|
|
87
|
+
>
|
|
88
|
+
<template #buttons>
|
|
89
|
+
<AwPageMenuButtons
|
|
90
|
+
v-if="!model.isNew()"
|
|
91
|
+
:items="[{ text: 'Delete', color: 'error', listeners: { click: delete } }]"
|
|
92
|
+
/>
|
|
93
|
+
</template>
|
|
94
|
+
</AwPageSingle>
|
|
95
|
+
|
|
96
|
+
// ❌ BAD - CTA button in #buttons slot
|
|
97
|
+
<AwPageSingle>
|
|
98
|
+
<template #buttons>
|
|
99
|
+
<AwButton @click="save">Save</AwButton> // Wrong!
|
|
100
|
+
</template>
|
|
101
|
+
</AwPageSingle>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Basic Create/Edit Page
|
|
105
|
+
|
|
106
|
+
### Minimal Example
|
|
107
|
+
|
|
108
|
+
```markup
|
|
109
|
+
<template>
|
|
110
|
+
<AwPageSingle
|
|
111
|
+
hide-menu
|
|
112
|
+
:title="pageTitle"
|
|
113
|
+
:action="saveButton"
|
|
114
|
+
@action="save"
|
|
115
|
+
>
|
|
116
|
+
<AwCard title="Customer Details">
|
|
117
|
+
<AwGrid>
|
|
118
|
+
<AwInput
|
|
119
|
+
v-model="customer.name"
|
|
120
|
+
label="Name"
|
|
121
|
+
:error="customer.errors.name"
|
|
122
|
+
required
|
|
123
|
+
/>
|
|
124
|
+
|
|
125
|
+
<AwInput
|
|
126
|
+
v-model="customer.email"
|
|
127
|
+
label="Email"
|
|
128
|
+
type="email"
|
|
129
|
+
:error="customer.errors.email"
|
|
130
|
+
required
|
|
131
|
+
/>
|
|
132
|
+
|
|
133
|
+
<AwTel
|
|
134
|
+
v-model="customer.phone"
|
|
135
|
+
label="Phone"
|
|
136
|
+
:error="customer.errors.phone"
|
|
137
|
+
/>
|
|
138
|
+
</AwGrid>
|
|
139
|
+
</AwCard>
|
|
140
|
+
|
|
141
|
+
<AwCard title="Profile picture">
|
|
142
|
+
<AwImageUpload
|
|
143
|
+
v-model="customer.avatar"
|
|
144
|
+
@loading="customer.saving = $event"
|
|
145
|
+
/>
|
|
146
|
+
</AwCard>
|
|
147
|
+
</AwPageSingle>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<script>
|
|
151
|
+
import Customer from '~/models/Customer'
|
|
152
|
+
|
|
153
|
+
export default {
|
|
154
|
+
layout: 'empty',
|
|
155
|
+
|
|
156
|
+
middleware: 'auth',
|
|
157
|
+
|
|
158
|
+
data() {
|
|
159
|
+
return {
|
|
160
|
+
customer: new Customer(
|
|
161
|
+
{ id: this.$route.params.id },
|
|
162
|
+
null,
|
|
163
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
computed: {
|
|
169
|
+
pageTitle() {
|
|
170
|
+
return this.customer.isNew() ? 'Create Customer' : 'Edit Customer'
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
saveButton() {
|
|
174
|
+
return {
|
|
175
|
+
text: 'Save',
|
|
176
|
+
loading: this.customer.saving
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async mounted() {
|
|
182
|
+
if (!this.customer.isNew()) {
|
|
183
|
+
try {
|
|
184
|
+
await this.customer.fetch()
|
|
185
|
+
} catch (error) {
|
|
186
|
+
this.$notify({
|
|
187
|
+
title: 'Customer not found',
|
|
188
|
+
type: 'error'
|
|
189
|
+
})
|
|
190
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/customers`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
methods: {
|
|
196
|
+
async save() {
|
|
197
|
+
try {
|
|
198
|
+
await this.customer.save()
|
|
199
|
+
|
|
200
|
+
this.$notify({
|
|
201
|
+
title: `Customer ${this.customer.isNew() ? 'created' : 'updated'} successfully`,
|
|
202
|
+
type: 'success'
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/customers`)
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this.$notify({
|
|
208
|
+
title: 'Failed to save customer',
|
|
209
|
+
type: 'error'
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
</script>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Preview Slot Patterns
|
|
219
|
+
|
|
220
|
+
We have two standard patterns for the `#preview` slot in `AwPageSingle` to maintain visual consistency across the application.
|
|
221
|
+
|
|
222
|
+
### Human Entities (Users, Customers)
|
|
223
|
+
|
|
224
|
+
Used for people. Displays a large avatar and the person's name.
|
|
225
|
+
|
|
226
|
+
```markup
|
|
227
|
+
<template #preview>
|
|
228
|
+
<AwCard class="text-center">
|
|
229
|
+
<AwAvatar
|
|
230
|
+
:src="user.avatar"
|
|
231
|
+
:name="userName"
|
|
232
|
+
size="240"
|
|
233
|
+
class="mx-auto"
|
|
234
|
+
/>
|
|
235
|
+
<AwHeadline class="mt-4">
|
|
236
|
+
{{ userName }}
|
|
237
|
+
</AwHeadline>
|
|
238
|
+
</AwCard>
|
|
239
|
+
</template>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Non-Human Entities (Services, Products, Locations)
|
|
243
|
+
|
|
244
|
+
Used for items with a representative image. Displays a large image with rounded corners.
|
|
245
|
+
|
|
246
|
+
```markup
|
|
247
|
+
<template #preview>
|
|
248
|
+
<AwCard style="--card-padding-x: 0.5rem; --card-padding-y: 0.5rem;">
|
|
249
|
+
<AwActionIcon
|
|
250
|
+
:size="380"
|
|
251
|
+
:image="item.image ? { src: item.image, alt: item.name } : null"
|
|
252
|
+
icon="awesio/image"
|
|
253
|
+
icon-color="mono-400"
|
|
254
|
+
color="mono-800"
|
|
255
|
+
class="w-full"
|
|
256
|
+
style="--icon-size: 48px; --radius: 0.5rem;"
|
|
257
|
+
/>
|
|
258
|
+
<AwHeadline class="p-4 mt-2">
|
|
259
|
+
{{ item.name }}
|
|
260
|
+
</AwHeadline>
|
|
261
|
+
</AwCard>
|
|
262
|
+
</template>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Image Uploads
|
|
266
|
+
|
|
267
|
+
For entities with images (avatars or item photos), provide an `AwImageUpload` component in its own `AwCard`.
|
|
268
|
+
|
|
269
|
+
```markup
|
|
270
|
+
<AwCard title="Profile picture">
|
|
271
|
+
<AwImageUpload
|
|
272
|
+
v-model="user.avatar"
|
|
273
|
+
@loading="user.saving = $event"
|
|
274
|
+
/>
|
|
275
|
+
</AwCard>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Key Points:**
|
|
279
|
+
- ✅ Use a separate `AwCard` with an appropriate title.
|
|
280
|
+
- ✅ Bind `v-model` to the model's image/avatar property.
|
|
281
|
+
- ✅ Listen to `@loading` to set the model's `saving` state, preventing form submission during upload.
|
|
282
|
+
|
|
283
|
+
## Model Initialization
|
|
284
|
+
|
|
285
|
+
### Constructor Pattern
|
|
286
|
+
|
|
287
|
+
**Model constructor signature:**
|
|
288
|
+
```javascript
|
|
289
|
+
new Model(attributes, collection, options)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Three arguments:**
|
|
293
|
+
1. `attributes` - Initial data (object)
|
|
294
|
+
2. `collection` - Parent collection or `null`
|
|
295
|
+
3. `options` - Additional options (shop_uuid, etc.)
|
|
296
|
+
|
|
297
|
+
### Create Page (New Model)
|
|
298
|
+
|
|
299
|
+
```javascript
|
|
300
|
+
data() {
|
|
301
|
+
return {
|
|
302
|
+
customer: new Customer(
|
|
303
|
+
{}, // Empty attributes for new record
|
|
304
|
+
null, // No parent collection
|
|
305
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Edit Page (Existing Model)
|
|
312
|
+
|
|
313
|
+
```javascript
|
|
314
|
+
data() {
|
|
315
|
+
return {
|
|
316
|
+
customer: new Customer(
|
|
317
|
+
{ id: this.$route.params.id }, // ID from route
|
|
318
|
+
null,
|
|
319
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async mounted() {
|
|
325
|
+
// Fetch existing data
|
|
326
|
+
if (!this.customer.isNew()) {
|
|
327
|
+
await this.customer.fetch()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Handling UUID Routes
|
|
333
|
+
|
|
334
|
+
For routes using 'new' as placeholder:
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
data() {
|
|
338
|
+
const uuid = this.$route.params.uuid
|
|
339
|
+
return {
|
|
340
|
+
template: new Template(
|
|
341
|
+
uuid === 'new' ? {} : { uuid },
|
|
342
|
+
null,
|
|
343
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async mounted() {
|
|
349
|
+
if (this.$route.params.uuid !== 'new') {
|
|
350
|
+
await this.template.fetch()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Using Same Template for Create/Edit
|
|
356
|
+
|
|
357
|
+
**Best Practice:** Use the same template for both create and edit pages. Write the template in `create.vue` and extend it in `_id.vue`:
|
|
358
|
+
|
|
359
|
+
**`create.vue`** (main template):
|
|
360
|
+
```javascript
|
|
361
|
+
export default {
|
|
362
|
+
layout: 'empty',
|
|
363
|
+
|
|
364
|
+
data() {
|
|
365
|
+
return {
|
|
366
|
+
service: new Service({ id: this.$route.params.id })
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
computed: {
|
|
371
|
+
pageTitle() {
|
|
372
|
+
return this.service.isNew() ? 'Create service' : 'Edit service'
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
actionButton() {
|
|
376
|
+
return {
|
|
377
|
+
text: this.service.isNew() ? 'Create' : 'Save',
|
|
378
|
+
loading: this.service.saving
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
async mounted() {
|
|
384
|
+
if (!this.service.isNew()) {
|
|
385
|
+
try {
|
|
386
|
+
await this.service.fetch()
|
|
387
|
+
} catch (error) {
|
|
388
|
+
this.$notify({
|
|
389
|
+
title: 'Service not found',
|
|
390
|
+
type: 'error'
|
|
391
|
+
})
|
|
392
|
+
this.$router.push('/services')
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
watch: {
|
|
398
|
+
'$route.params.id': {
|
|
399
|
+
handler(newId) {
|
|
400
|
+
this.service = new Service({ id: newId })
|
|
401
|
+
if (newId) {
|
|
402
|
+
this.service.fetch()
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**`_id.vue`** (extends create.vue):
|
|
411
|
+
```javascript
|
|
412
|
+
<script>
|
|
413
|
+
import CreatePage from './create.vue'
|
|
414
|
+
|
|
415
|
+
export default {
|
|
416
|
+
extends: CreatePage
|
|
417
|
+
}
|
|
418
|
+
</script>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Benefits:**
|
|
422
|
+
- ✅ Single source of truth for form structure
|
|
423
|
+
- ✅ Automatic create/edit mode detection via `model.isNew()`
|
|
424
|
+
- ✅ Computed props handle dynamic titles and button texts
|
|
425
|
+
- ✅ Less code duplication
|
|
426
|
+
|
|
427
|
+
## Page Title Patterns
|
|
428
|
+
|
|
429
|
+
### Dynamic Title Based on State
|
|
430
|
+
|
|
431
|
+
```javascript
|
|
432
|
+
computed: {
|
|
433
|
+
pageTitle() {
|
|
434
|
+
return this.model.isNew()
|
|
435
|
+
? 'Create Product'
|
|
436
|
+
: 'Edit Product'
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Title with Item Name
|
|
442
|
+
|
|
443
|
+
```javascript
|
|
444
|
+
computed: {
|
|
445
|
+
pageTitle() {
|
|
446
|
+
if (this.model.isNew()) {
|
|
447
|
+
return 'Create Template'
|
|
448
|
+
}
|
|
449
|
+
return this.model.name || 'Edit Template'
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Title with ID
|
|
455
|
+
|
|
456
|
+
```javascript
|
|
457
|
+
computed: {
|
|
458
|
+
pageTitle() {
|
|
459
|
+
return this.model.isNew()
|
|
460
|
+
? 'New Invoice'
|
|
461
|
+
: `Invoice #${this.model.invoice_number}`
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Person selectors with avatars (users/customers/instructors)
|
|
467
|
+
|
|
468
|
+
For detail/create/edit pages that pick a person (user, customer, instructor), use `AwSelectObject` with `icon` and `option-label` slots to show avatars and a fallback icon.
|
|
469
|
+
|
|
470
|
+
```markup
|
|
471
|
+
<AwSelectObject
|
|
472
|
+
v-model="selectedPerson"
|
|
473
|
+
:options="searchPeople"
|
|
474
|
+
:option-label="person => `${person.first_name} ${person.last_name}`"
|
|
475
|
+
track-by="id"
|
|
476
|
+
clearable
|
|
477
|
+
>
|
|
478
|
+
<template #icon="{ option }">
|
|
479
|
+
<AwAvatar
|
|
480
|
+
v-if="option"
|
|
481
|
+
class="mx-3"
|
|
482
|
+
:src="option.avatar"
|
|
483
|
+
:name="`${option.first_name} ${option.last_name}`"
|
|
484
|
+
size="24"
|
|
485
|
+
icon="awesio/user"
|
|
486
|
+
/>
|
|
487
|
+
<AwActionIcon
|
|
488
|
+
v-else
|
|
489
|
+
class="mx-3 rounded-full"
|
|
490
|
+
icon="awesio/user"
|
|
491
|
+
size="xs"
|
|
492
|
+
/>
|
|
493
|
+
</template>
|
|
494
|
+
|
|
495
|
+
<template #option-label="{ option, highlightSearch }">
|
|
496
|
+
<div class="flex items-center gap-2">
|
|
497
|
+
<AwAvatar
|
|
498
|
+
:src="option.avatar"
|
|
499
|
+
:name="`${option.first_name} ${option.last_name}`"
|
|
500
|
+
size="24"
|
|
501
|
+
class="-ml-1"
|
|
502
|
+
icon="awesio/user"
|
|
503
|
+
/>
|
|
504
|
+
<div class="leading-tight">
|
|
505
|
+
<div v-html="highlightSearch(`${option.first_name} ${option.last_name}`)" />
|
|
506
|
+
<div v-if="option.email" class="text-sm text-mono-500">
|
|
507
|
+
{{ option.email }}
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</template>
|
|
512
|
+
</AwSelectObject>
|
|
513
|
+
|
|
514
|
+
<script>
|
|
515
|
+
export default {
|
|
516
|
+
methods: {
|
|
517
|
+
searchPeople(search, page) {
|
|
518
|
+
return {
|
|
519
|
+
url: '/api/users',
|
|
520
|
+
params: { search, page }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
</script>
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## Form Layouts
|
|
529
|
+
|
|
530
|
+
### Single Column Layout
|
|
531
|
+
|
|
532
|
+
Simple forms with basic fields:
|
|
533
|
+
|
|
534
|
+
```markup
|
|
535
|
+
<template>
|
|
536
|
+
<AwPageSingle hide-menu :title="pageTitle" :action="saveButton" @action="save">
|
|
537
|
+
<AwCard title="Settings">
|
|
538
|
+
<AwGrid>
|
|
539
|
+
<AwInput v-model="settings.company_name" label="Company Name" />
|
|
540
|
+
<AwInput v-model="settings.email" label="Email" />
|
|
541
|
+
<AwTel v-model="settings.phone" label="Phone" />
|
|
542
|
+
<AwTextarea v-model="settings.address" label="Address" />
|
|
543
|
+
</AwGrid>
|
|
544
|
+
</AwCard>
|
|
545
|
+
</AwPageSingle>
|
|
546
|
+
</template>
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Two-Column Layout
|
|
550
|
+
|
|
551
|
+
Complex forms or forms with sidebar:
|
|
552
|
+
|
|
553
|
+
```markup
|
|
554
|
+
<template>
|
|
555
|
+
<AwPageSingle hide-menu :title="pageTitle" :action="saveButton" @action="save">
|
|
556
|
+
<AwGrid :col="{ lg: 3 }">
|
|
557
|
+
<!-- Main content (2/3 width) -->
|
|
558
|
+
<div span="{ lg: 2 }" class="space-y-6">
|
|
559
|
+
<AwCard title="Product Details">
|
|
560
|
+
<AwGrid>
|
|
561
|
+
<AwInput v-model="product.name" label="Name" />
|
|
562
|
+
<AwInput v-model="product.sku" label="SKU" />
|
|
563
|
+
<AwTextarea v-model="product.description" label="Description" />
|
|
564
|
+
</AwGrid>
|
|
565
|
+
</AwCard>
|
|
566
|
+
|
|
567
|
+
<AwCard title="Pricing">
|
|
568
|
+
<AwGrid>
|
|
569
|
+
<AwMoney v-model="product.price" label="Price" />
|
|
570
|
+
<AwMoney v-model="product.cost" label="Cost" />
|
|
571
|
+
</AwGrid>
|
|
572
|
+
</AwCard>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**⚠️ Important: Use AwMoney for all money fields**
|
|
576
|
+
|
|
577
|
+
For all money-related fields (price, cost, discount_amount, tax_amount, etc.), always use `AwMoney` instead of `AwInput` with `type="number"`:
|
|
578
|
+
|
|
579
|
+
```markup
|
|
580
|
+
<!-- ✅ GOOD - Use AwMoney for money fields -->
|
|
581
|
+
<AwMoney v-model="product.price" label="Price" :error="product.errors.price" />
|
|
582
|
+
<AwMoney v-model="product.cost" label="Cost" :error="product.errors.cost" />
|
|
583
|
+
<AwMoney v-model="order.discount_amount" label="Discount" :error="order.errors.discount_amount" />
|
|
584
|
+
|
|
585
|
+
<!-- ❌ BAD - Don't use AwInput with type="number" for money -->
|
|
586
|
+
<AwInput v-model="product.price" label="Price" type="number" />
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
<!-- Sidebar (1/3 width) -->
|
|
590
|
+
<div class="space-y-6">
|
|
591
|
+
<AwCard title="Status">
|
|
592
|
+
<AwSwitcher v-model="product.is_active" label="Active" />
|
|
593
|
+
<AwSwitcher v-model="product.is_featured" label="Featured" />
|
|
594
|
+
</AwCard>
|
|
595
|
+
|
|
596
|
+
<AwCard title="Category">
|
|
597
|
+
<AwSelect
|
|
598
|
+
v-model="product.category_id"
|
|
599
|
+
:options="categories"
|
|
600
|
+
label="Category"
|
|
601
|
+
/>
|
|
602
|
+
</AwCard>
|
|
603
|
+
</div>
|
|
604
|
+
</AwGrid>
|
|
605
|
+
</AwPageSingle>
|
|
606
|
+
</template>
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Multiple Sections
|
|
610
|
+
|
|
611
|
+
Large forms organized by sections:
|
|
612
|
+
|
|
613
|
+
```markup
|
|
614
|
+
<template>
|
|
615
|
+
<AwPageSingle hide-menu :title="pageTitle" :action="saveButton" @action="save">
|
|
616
|
+
<div class="space-y-6">
|
|
617
|
+
<!-- Basic Information -->
|
|
618
|
+
<AwCard title="Basic Information">
|
|
619
|
+
<AwGrid>
|
|
620
|
+
<AwInput v-model="user.first_name" label="First Name" />
|
|
621
|
+
<AwInput v-model="user.last_name" label="Last Name" />
|
|
622
|
+
<AwInput v-model="user.email" label="Email" />
|
|
623
|
+
</AwGrid>
|
|
624
|
+
</AwCard>
|
|
625
|
+
|
|
626
|
+
<!-- Contact Details -->
|
|
627
|
+
<AwCard title="Contact Details">
|
|
628
|
+
<AwGrid>
|
|
629
|
+
<AwTel v-model="user.phone" label="Phone" />
|
|
630
|
+
<AwAddress v-model="user.address" label="Address" />
|
|
631
|
+
</AwGrid>
|
|
632
|
+
</AwCard>
|
|
633
|
+
|
|
634
|
+
<!-- Permissions -->
|
|
635
|
+
<AwCard title="Permissions">
|
|
636
|
+
<AwSelect
|
|
637
|
+
v-model="user.role"
|
|
638
|
+
:options="['user', 'manager', 'admin']"
|
|
639
|
+
label="Role"
|
|
640
|
+
/>
|
|
641
|
+
<AwCheckbox v-model="user.can_export" label="Can export data" />
|
|
642
|
+
<AwCheckbox v-model="user.can_import" label="Can import data" />
|
|
643
|
+
</AwCard>
|
|
644
|
+
</div>
|
|
645
|
+
</AwPageSingle>
|
|
646
|
+
</template>
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
## Validation & Error Handling
|
|
650
|
+
|
|
651
|
+
### Field-Level Errors
|
|
652
|
+
|
|
653
|
+
```markup
|
|
654
|
+
<AwInput
|
|
655
|
+
v-model="model.name"
|
|
656
|
+
label="Name"
|
|
657
|
+
:error="model.errors.name"
|
|
658
|
+
required
|
|
659
|
+
/>
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**How it works:**
|
|
663
|
+
1. Model's `save()` sends request to Laravel
|
|
664
|
+
2. Laravel returns 422 with validation errors
|
|
665
|
+
3. Model populates `errors` object automatically
|
|
666
|
+
4. Error displays below field
|
|
667
|
+
|
|
668
|
+
### Check for Errors After Save
|
|
669
|
+
|
|
670
|
+
```javascript
|
|
671
|
+
async save() {
|
|
672
|
+
await this.model.save()
|
|
673
|
+
|
|
674
|
+
// Check if validation failed
|
|
675
|
+
if (Object.keys(this.model.errors).length > 0) {
|
|
676
|
+
this.$notify({
|
|
677
|
+
title: 'Please fix validation errors',
|
|
678
|
+
type: 'error'
|
|
679
|
+
})
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Success path
|
|
684
|
+
this.$notify({
|
|
685
|
+
title: 'Saved successfully',
|
|
686
|
+
type: 'success'
|
|
687
|
+
})
|
|
688
|
+
this.$router.push('/list')
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Handle Fetch Errors
|
|
693
|
+
|
|
694
|
+
```javascript
|
|
695
|
+
async mounted() {
|
|
696
|
+
if (!this.model.isNew()) {
|
|
697
|
+
try {
|
|
698
|
+
await this.model.fetch()
|
|
699
|
+
} catch (error) {
|
|
700
|
+
// 404 - Record not found
|
|
701
|
+
if (error.response?.status === 404) {
|
|
702
|
+
this.$notify({
|
|
703
|
+
title: 'Record not found',
|
|
704
|
+
type: 'error'
|
|
705
|
+
})
|
|
706
|
+
this.$router.push('/list')
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Other errors
|
|
711
|
+
this.$notify({
|
|
712
|
+
title: 'Failed to load data',
|
|
713
|
+
type: 'error'
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Custom Validation Messages
|
|
721
|
+
|
|
722
|
+
```markup
|
|
723
|
+
<template>
|
|
724
|
+
<AwInput
|
|
725
|
+
v-model="model.email"
|
|
726
|
+
label="Email"
|
|
727
|
+
:error="emailError"
|
|
728
|
+
required
|
|
729
|
+
/>
|
|
730
|
+
</template>
|
|
731
|
+
|
|
732
|
+
<script>
|
|
733
|
+
export default {
|
|
734
|
+
computed: {
|
|
735
|
+
emailError() {
|
|
736
|
+
if (this.model.errors.email) {
|
|
737
|
+
return this.model.errors.email
|
|
738
|
+
}
|
|
739
|
+
if (this.model.email && !this.isValidEmail(this.model.email)) {
|
|
740
|
+
return 'Please enter a valid email address'
|
|
741
|
+
}
|
|
742
|
+
return null
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
methods: {
|
|
747
|
+
isValidEmail(email) {
|
|
748
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
</script>
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
## Loading & Saving States
|
|
756
|
+
|
|
757
|
+
### Save Button Loading
|
|
758
|
+
|
|
759
|
+
```javascript
|
|
760
|
+
computed: {
|
|
761
|
+
saveButton() {
|
|
762
|
+
return {
|
|
763
|
+
text: 'Save',
|
|
764
|
+
loading: this.model.saving // Automatically set by vue-mc
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Fetch Loading State
|
|
771
|
+
|
|
772
|
+
When loading data without a card wrapper:
|
|
773
|
+
|
|
774
|
+
```markup
|
|
775
|
+
<template>
|
|
776
|
+
<AwPageSingle hide-menu :title="pageTitle">
|
|
777
|
+
<AwContentPlaceholder v-if="loading" type="form" :lines="6" />
|
|
778
|
+
|
|
779
|
+
<div v-else class="space-y-6">
|
|
780
|
+
<!-- Form content -->
|
|
781
|
+
</div>
|
|
782
|
+
</AwPageSingle>
|
|
783
|
+
</template>
|
|
784
|
+
|
|
785
|
+
<script>
|
|
786
|
+
export default {
|
|
787
|
+
data() {
|
|
788
|
+
return {
|
|
789
|
+
loading: true
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
|
|
793
|
+
async mounted() {
|
|
794
|
+
if (!this.model.isNew()) {
|
|
795
|
+
try {
|
|
796
|
+
await this.model.fetch()
|
|
797
|
+
} finally {
|
|
798
|
+
this.loading = false
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
this.loading = false
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
</script>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
When using `AwCard`, place the placeholder inside the card to replace the content:
|
|
809
|
+
|
|
810
|
+
```markup
|
|
811
|
+
<template>
|
|
812
|
+
<AwPageSingle hide-menu :title="pageTitle">
|
|
813
|
+
<AwCard title="Details">
|
|
814
|
+
<AwContentPlaceholder v-if="model.loading && !model.isNew()" type="form" :lines="4" />
|
|
815
|
+
|
|
816
|
+
<AwGrid v-else>
|
|
817
|
+
<AwInput v-model="model.name" label="Name" />
|
|
818
|
+
<AwInput v-model="model.email" label="Email" />
|
|
819
|
+
</AwGrid>
|
|
820
|
+
</AwCard>
|
|
821
|
+
</AwPageSingle>
|
|
822
|
+
</template>
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Complex Loading State
|
|
826
|
+
|
|
827
|
+
For pages with multiple sections, use multiple placeholders:
|
|
828
|
+
|
|
829
|
+
```markup
|
|
830
|
+
<template>
|
|
831
|
+
<AwPageSingle hide-menu :title="pageTitle">
|
|
832
|
+
<AwGrid v-if="loading" :col="{ lg: 3 }">
|
|
833
|
+
<div span="{ lg: 2 }">
|
|
834
|
+
<AwContentPlaceholder type="form" :lines="8" />
|
|
835
|
+
</div>
|
|
836
|
+
<div>
|
|
837
|
+
<AwContentPlaceholder type="form" :lines="4" />
|
|
838
|
+
</div>
|
|
839
|
+
</AwGrid>
|
|
840
|
+
|
|
841
|
+
<div v-else class="space-y-6">
|
|
842
|
+
<!-- Form content -->
|
|
843
|
+
</div>
|
|
844
|
+
</AwPageSingle>
|
|
845
|
+
</template>
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
### Progress Indicators in Header
|
|
849
|
+
|
|
850
|
+
```markup
|
|
851
|
+
<template>
|
|
852
|
+
<AwPageSingle hide-menu :title="pageTitle" :action="saveButton" @action="save">
|
|
853
|
+
<!-- Custom header indicators -->
|
|
854
|
+
<template #buttons>
|
|
855
|
+
<div v-if="autoSaving" class="flex items-center gap-2 text-sm text-secondary">
|
|
856
|
+
<AwProgress size="sm" indeterminate />
|
|
857
|
+
<span>Saving...</span>
|
|
858
|
+
</div>
|
|
859
|
+
<div v-else-if="lastSaved" class="text-sm text-success">
|
|
860
|
+
Saved {{ $dayjs(lastSaved).fromNow() }}
|
|
861
|
+
</div>
|
|
862
|
+
</template>
|
|
863
|
+
|
|
864
|
+
<!-- Form content -->
|
|
865
|
+
</AwPageSingle>
|
|
866
|
+
</template>
|
|
867
|
+
|
|
868
|
+
<script>
|
|
869
|
+
export default {
|
|
870
|
+
data() {
|
|
871
|
+
return {
|
|
872
|
+
autoSaving: false,
|
|
873
|
+
lastSaved: null
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
watch: {
|
|
878
|
+
// Auto-save on changes
|
|
879
|
+
'model.$attributes': {
|
|
880
|
+
handler() {
|
|
881
|
+
this.debouncedSave()
|
|
882
|
+
},
|
|
883
|
+
deep: true
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
|
|
887
|
+
methods: {
|
|
888
|
+
debouncedSave: _.debounce(function() {
|
|
889
|
+
this.autoSave()
|
|
890
|
+
}, 2000),
|
|
891
|
+
|
|
892
|
+
async autoSave() {
|
|
893
|
+
if (this.model.isNew()) return
|
|
894
|
+
|
|
895
|
+
this.autoSaving = true
|
|
896
|
+
try {
|
|
897
|
+
await this.model.save()
|
|
898
|
+
this.lastSaved = new Date()
|
|
899
|
+
} catch (error) {
|
|
900
|
+
// Silently fail auto-save
|
|
901
|
+
} finally {
|
|
902
|
+
this.autoSaving = false
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
</script>
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
## Action Button Patterns
|
|
911
|
+
|
|
912
|
+
**⚠️ Important: CTA button MUST use `:action` prop**
|
|
913
|
+
|
|
914
|
+
The primary action button (Save, Create, etc.) MUST use the `:action` prop and `@action` event on `AwPageSingle`. Do NOT put the CTA button in the `#buttons` slot.
|
|
915
|
+
|
|
916
|
+
The `#buttons` slot is ONLY for secondary actions (Delete, Disable, etc.) and they MUST be wrapped in `AwPageMenuButtons` without `cta` prop.
|
|
917
|
+
|
|
918
|
+
### Delete Button
|
|
919
|
+
|
|
920
|
+
Use the `#buttons` slot with `AwPageMenuButtons` for delete and other secondary actions:
|
|
921
|
+
|
|
922
|
+
```markup
|
|
923
|
+
<template>
|
|
924
|
+
<AwPageSingle
|
|
925
|
+
hide-menu
|
|
926
|
+
:title="pageTitle"
|
|
927
|
+
:action="saveButton"
|
|
928
|
+
@action="save"
|
|
929
|
+
>
|
|
930
|
+
<template #buttons>
|
|
931
|
+
<AwPageMenuButtons
|
|
932
|
+
v-if="!model.isNew()"
|
|
933
|
+
:items="[
|
|
934
|
+
{
|
|
935
|
+
text: 'Delete',
|
|
936
|
+
icon: 'awesio/delete',
|
|
937
|
+
color: 'error',
|
|
938
|
+
listeners: { click: deleteItem }
|
|
939
|
+
}
|
|
940
|
+
]"
|
|
941
|
+
/>
|
|
942
|
+
</template>
|
|
943
|
+
|
|
944
|
+
<!-- Form content -->
|
|
945
|
+
</AwPageSingle>
|
|
946
|
+
</template>
|
|
947
|
+
|
|
948
|
+
<script>
|
|
949
|
+
export default {
|
|
950
|
+
computed: {
|
|
951
|
+
saveButton() {
|
|
952
|
+
return {
|
|
953
|
+
text: this.model.isNew() ? 'Create' : 'Save',
|
|
954
|
+
loading: this.model.saving
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
|
|
959
|
+
methods: {
|
|
960
|
+
async save() {
|
|
961
|
+
try {
|
|
962
|
+
await this.model.save()
|
|
963
|
+
this.$notify({
|
|
964
|
+
title: `Item ${this.model.isNew() ? 'created' : 'updated'} successfully`,
|
|
965
|
+
type: 'success'
|
|
966
|
+
})
|
|
967
|
+
this.$router.push('/list')
|
|
968
|
+
} catch (error) {
|
|
969
|
+
this.$notify({
|
|
970
|
+
title: 'Failed to save item',
|
|
971
|
+
type: 'error'
|
|
972
|
+
})
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
async deleteItem() {
|
|
977
|
+
try {
|
|
978
|
+
await this.$confirm({
|
|
979
|
+
title: 'Delete Item',
|
|
980
|
+
message: `Are you sure you want to delete "${this.model.name}"?`
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
await this.model.delete()
|
|
984
|
+
this.$notify({
|
|
985
|
+
title: 'Item deleted successfully',
|
|
986
|
+
type: 'success'
|
|
987
|
+
})
|
|
988
|
+
this.$router.push('/list')
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (this.$confirm.isCancel(error)) {
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
this.$notify({
|
|
995
|
+
title: 'Failed to delete item',
|
|
996
|
+
type: 'error'
|
|
997
|
+
})
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
</script>
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
**Key points:**
|
|
1006
|
+
- ✅ Delete button wrapped in `AwPageMenuButtons`
|
|
1007
|
+
- ✅ Delete button has `color: 'error'` prop
|
|
1008
|
+
- ✅ Delete button only shows when editing (`!model.isNew()`)
|
|
1009
|
+
- ✅ `$confirm` wrapped in try-catch with `$confirm.isCancel()` check
|
|
1010
|
+
|
|
1011
|
+
### Multiple Secondary Actions
|
|
1012
|
+
|
|
1013
|
+
For multiple secondary actions, use `#buttons` slot with `AwPageMenuButtons`:
|
|
1014
|
+
|
|
1015
|
+
```markup
|
|
1016
|
+
<template>
|
|
1017
|
+
<AwPageSingle
|
|
1018
|
+
hide-menu
|
|
1019
|
+
:title="pageTitle"
|
|
1020
|
+
:action="publishButton"
|
|
1021
|
+
@action="publish"
|
|
1022
|
+
>
|
|
1023
|
+
<template #buttons>
|
|
1024
|
+
<AwPageMenuButtons
|
|
1025
|
+
:items="[
|
|
1026
|
+
{
|
|
1027
|
+
text: 'Preview',
|
|
1028
|
+
icon: 'awesio/eye',
|
|
1029
|
+
listeners: { click: preview }
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
text: 'Save as Draft',
|
|
1033
|
+
icon: 'awesio/save',
|
|
1034
|
+
listeners: { click: saveAsDraft }
|
|
1035
|
+
}
|
|
1036
|
+
]"
|
|
1037
|
+
/>
|
|
1038
|
+
</template>
|
|
1039
|
+
|
|
1040
|
+
<!-- Form content -->
|
|
1041
|
+
</AwPageSingle>
|
|
1042
|
+
</template>
|
|
1043
|
+
|
|
1044
|
+
<script>
|
|
1045
|
+
export default {
|
|
1046
|
+
computed: {
|
|
1047
|
+
publishButton() {
|
|
1048
|
+
return {
|
|
1049
|
+
key: 'publish',
|
|
1050
|
+
label: 'Publish',
|
|
1051
|
+
loading: this.model.saving,
|
|
1052
|
+
color: 'accent'
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
methods: {
|
|
1058
|
+
async publish() {
|
|
1059
|
+
// Publish logic
|
|
1060
|
+
},
|
|
1061
|
+
|
|
1062
|
+
preview() {
|
|
1063
|
+
// Preview logic
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
async saveAsDraft() {
|
|
1067
|
+
// Save as draft logic
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
</script>
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
### Dropdown Menu for More Actions
|
|
1075
|
+
|
|
1076
|
+
For many secondary actions, use the `#buttons` slot with a dropdown:
|
|
1077
|
+
|
|
1078
|
+
```markup
|
|
1079
|
+
<template>
|
|
1080
|
+
<AwPageSingle
|
|
1081
|
+
hide-menu
|
|
1082
|
+
:title="pageTitle"
|
|
1083
|
+
:action="saveButton"
|
|
1084
|
+
@action="save"
|
|
1085
|
+
>
|
|
1086
|
+
<template #buttons>
|
|
1087
|
+
<AwDropdown>
|
|
1088
|
+
<template #trigger>
|
|
1089
|
+
<AwButton text="More" icon="dots-vertical" />
|
|
1090
|
+
</template>
|
|
1091
|
+
|
|
1092
|
+
<AwButton @click="duplicate" theme="text" text="Duplicate" />
|
|
1093
|
+
<AwButton @click="archive" theme="text" text="Archive" />
|
|
1094
|
+
<AwButton @click="deleteItem" theme="text" color="error" text="Delete" />
|
|
1095
|
+
</AwDropdown>
|
|
1096
|
+
</template>
|
|
1097
|
+
|
|
1098
|
+
<!-- Form content -->
|
|
1099
|
+
</AwPageSingle>
|
|
1100
|
+
</template>
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
## Complete Examples
|
|
1104
|
+
|
|
1105
|
+
### Simple Create/Edit Page
|
|
1106
|
+
|
|
1107
|
+
```markup
|
|
1108
|
+
<template>
|
|
1109
|
+
<AwPageSingle
|
|
1110
|
+
hide-menu
|
|
1111
|
+
:title="pageTitle"
|
|
1112
|
+
:action="saveButton"
|
|
1113
|
+
@action="save"
|
|
1114
|
+
>
|
|
1115
|
+
<AwCard title="Template Details">
|
|
1116
|
+
<AwGrid>
|
|
1117
|
+
<AwInput
|
|
1118
|
+
v-model="template.name"
|
|
1119
|
+
label="Template Name"
|
|
1120
|
+
:error="template.errors.name"
|
|
1121
|
+
required
|
|
1122
|
+
/>
|
|
1123
|
+
|
|
1124
|
+
<AwSelect
|
|
1125
|
+
v-model="template.channel"
|
|
1126
|
+
:options="['email', 'sms', 'push']"
|
|
1127
|
+
label="Channel"
|
|
1128
|
+
:error="template.errors.channel"
|
|
1129
|
+
required
|
|
1130
|
+
/>
|
|
1131
|
+
|
|
1132
|
+
<AwInput
|
|
1133
|
+
v-model="template.subject"
|
|
1134
|
+
label="Subject"
|
|
1135
|
+
:error="template.errors.subject"
|
|
1136
|
+
class="col-span-2"
|
|
1137
|
+
/>
|
|
1138
|
+
|
|
1139
|
+
<AwTextarea
|
|
1140
|
+
v-model="template.body"
|
|
1141
|
+
label="Message Body"
|
|
1142
|
+
:error="template.errors.body"
|
|
1143
|
+
:rows="8"
|
|
1144
|
+
class="col-span-2"
|
|
1145
|
+
required
|
|
1146
|
+
/>
|
|
1147
|
+
|
|
1148
|
+
<AwSwitcher
|
|
1149
|
+
v-model="template.is_active"
|
|
1150
|
+
label="Active"
|
|
1151
|
+
/>
|
|
1152
|
+
</AwGrid>
|
|
1153
|
+
</AwCard>
|
|
1154
|
+
</AwPageSingle>
|
|
1155
|
+
</template>
|
|
1156
|
+
|
|
1157
|
+
<script>
|
|
1158
|
+
import Template from '~/models/Template'
|
|
1159
|
+
|
|
1160
|
+
export default {
|
|
1161
|
+
middleware: 'auth',
|
|
1162
|
+
|
|
1163
|
+
data() {
|
|
1164
|
+
const uuid = this.$route.params.uuid
|
|
1165
|
+
return {
|
|
1166
|
+
template: new Template(
|
|
1167
|
+
uuid === 'new' ? {} : { uuid },
|
|
1168
|
+
null,
|
|
1169
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
computed: {
|
|
1175
|
+
pageTitle() {
|
|
1176
|
+
return this.template.isNew() ? 'Create Template' : 'Edit Template'
|
|
1177
|
+
},
|
|
1178
|
+
|
|
1179
|
+
saveButton() {
|
|
1180
|
+
return {
|
|
1181
|
+
text: this.template.isNew() ? 'Create' : 'Save',
|
|
1182
|
+
loading: this.template.saving
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
async mounted() {
|
|
1188
|
+
if (this.$route.params.uuid !== 'new') {
|
|
1189
|
+
try {
|
|
1190
|
+
await this.template.fetch()
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
this.$notify({
|
|
1193
|
+
title: 'Template not found',
|
|
1194
|
+
type: 'error'
|
|
1195
|
+
})
|
|
1196
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/templates`)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
methods: {
|
|
1202
|
+
async save() {
|
|
1203
|
+
try {
|
|
1204
|
+
await this.template.save()
|
|
1205
|
+
|
|
1206
|
+
this.$notify({
|
|
1207
|
+
title: `Template ${this.template.isNew() ? 'created' : 'updated'} successfully`,
|
|
1208
|
+
type: 'success'
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/templates`)
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
this.$notify({
|
|
1214
|
+
title: 'Failed to save template',
|
|
1215
|
+
type: 'error'
|
|
1216
|
+
})
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
</script>
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
### Complex Multi-Section Page
|
|
1225
|
+
|
|
1226
|
+
```markup
|
|
1227
|
+
<template>
|
|
1228
|
+
<AwPageSingle
|
|
1229
|
+
hide-menu
|
|
1230
|
+
:title="pageTitle"
|
|
1231
|
+
:action="saveButton"
|
|
1232
|
+
@action="save"
|
|
1233
|
+
>
|
|
1234
|
+
<template #buttons>
|
|
1235
|
+
<AwPageMenuButtons
|
|
1236
|
+
v-if="!product.isNew()"
|
|
1237
|
+
:items="[
|
|
1238
|
+
{
|
|
1239
|
+
text: 'Delete',
|
|
1240
|
+
icon: 'awesio/delete',
|
|
1241
|
+
color: 'error',
|
|
1242
|
+
listeners: { click: deleteProduct }
|
|
1243
|
+
}
|
|
1244
|
+
]"
|
|
1245
|
+
/>
|
|
1246
|
+
</template>
|
|
1247
|
+
|
|
1248
|
+
<AwGrid :col="{ lg: 3 }">
|
|
1249
|
+
<!-- Main content -->
|
|
1250
|
+
<div span="{ lg: 2 }" class="space-y-6">
|
|
1251
|
+
<!-- Basic Details -->
|
|
1252
|
+
<AwCard title="Product Details">
|
|
1253
|
+
<AwGrid>
|
|
1254
|
+
<AwInput
|
|
1255
|
+
v-model="product.name"
|
|
1256
|
+
label="Product Name"
|
|
1257
|
+
:error="product.errors.name"
|
|
1258
|
+
required
|
|
1259
|
+
class="col-span-2"
|
|
1260
|
+
/>
|
|
1261
|
+
|
|
1262
|
+
<AwInput
|
|
1263
|
+
v-model="product.sku"
|
|
1264
|
+
label="SKU"
|
|
1265
|
+
:error="product.errors.sku"
|
|
1266
|
+
/>
|
|
1267
|
+
|
|
1268
|
+
<AwSelect
|
|
1269
|
+
v-model="product.category_id"
|
|
1270
|
+
:options="loadCategories"
|
|
1271
|
+
option-label="name"
|
|
1272
|
+
track-by="id"
|
|
1273
|
+
label="Category"
|
|
1274
|
+
:error="product.errors.category_id"
|
|
1275
|
+
/>
|
|
1276
|
+
|
|
1277
|
+
<AwTextarea
|
|
1278
|
+
v-model="product.description"
|
|
1279
|
+
label="Description"
|
|
1280
|
+
:error="product.errors.description"
|
|
1281
|
+
:rows="6"
|
|
1282
|
+
class="col-span-2"
|
|
1283
|
+
/>
|
|
1284
|
+
</AwGrid>
|
|
1285
|
+
</AwCard>
|
|
1286
|
+
|
|
1287
|
+
<!-- Pricing -->
|
|
1288
|
+
<AwCard title="Pricing">
|
|
1289
|
+
<AwGrid>
|
|
1290
|
+
<AwMoney
|
|
1291
|
+
v-model="product.price"
|
|
1292
|
+
label="Retail Price"
|
|
1293
|
+
:error="product.errors.price"
|
|
1294
|
+
required
|
|
1295
|
+
/>
|
|
1296
|
+
|
|
1297
|
+
<AwMoney
|
|
1298
|
+
v-model="product.cost"
|
|
1299
|
+
label="Cost"
|
|
1300
|
+
:error="product.errors.cost"
|
|
1301
|
+
/>
|
|
1302
|
+
|
|
1303
|
+
<AwInput
|
|
1304
|
+
v-model="product.compare_at_price"
|
|
1305
|
+
label="Compare at Price"
|
|
1306
|
+
type="number"
|
|
1307
|
+
:error="product.errors.compare_at_price"
|
|
1308
|
+
/>
|
|
1309
|
+
</AwGrid>
|
|
1310
|
+
</AwCard>
|
|
1311
|
+
|
|
1312
|
+
<!-- Images -->
|
|
1313
|
+
<AwCard title="Images">
|
|
1314
|
+
<AwUploader
|
|
1315
|
+
v-model="product.images"
|
|
1316
|
+
label="Product Images"
|
|
1317
|
+
:error="product.errors.images"
|
|
1318
|
+
accept="image/*"
|
|
1319
|
+
multiple
|
|
1320
|
+
/>
|
|
1321
|
+
</AwCard>
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
<!-- Sidebar -->
|
|
1325
|
+
<div class="space-y-6">
|
|
1326
|
+
<!-- Status -->
|
|
1327
|
+
<AwCard title="Status">
|
|
1328
|
+
<div class="space-y-4">
|
|
1329
|
+
<AwSwitcher
|
|
1330
|
+
v-model="product.is_active"
|
|
1331
|
+
label="Active"
|
|
1332
|
+
/>
|
|
1333
|
+
<AwSwitcher
|
|
1334
|
+
v-model="product.is_featured"
|
|
1335
|
+
label="Featured"
|
|
1336
|
+
/>
|
|
1337
|
+
</div>
|
|
1338
|
+
</AwCard>
|
|
1339
|
+
|
|
1340
|
+
<!-- Inventory -->
|
|
1341
|
+
<AwCard title="Inventory">
|
|
1342
|
+
<AwGrid>
|
|
1343
|
+
<AwInput
|
|
1344
|
+
v-model="product.stock_quantity"
|
|
1345
|
+
label="Stock Quantity"
|
|
1346
|
+
type="number"
|
|
1347
|
+
:error="product.errors.stock_quantity"
|
|
1348
|
+
/>
|
|
1349
|
+
|
|
1350
|
+
<AwSwitcher
|
|
1351
|
+
v-model="product.track_inventory"
|
|
1352
|
+
label="Track Inventory"
|
|
1353
|
+
/>
|
|
1354
|
+
</AwGrid>
|
|
1355
|
+
</AwCard>
|
|
1356
|
+
|
|
1357
|
+
<!-- Shipping -->
|
|
1358
|
+
<AwCard title="Shipping">
|
|
1359
|
+
<AwGrid>
|
|
1360
|
+
<AwInput
|
|
1361
|
+
v-model="product.weight"
|
|
1362
|
+
label="Weight (kg)"
|
|
1363
|
+
type="number"
|
|
1364
|
+
:error="product.errors.weight"
|
|
1365
|
+
/>
|
|
1366
|
+
|
|
1367
|
+
<AwSwitcher
|
|
1368
|
+
v-model="product.requires_shipping"
|
|
1369
|
+
label="Requires Shipping"
|
|
1370
|
+
/>
|
|
1371
|
+
</AwGrid>
|
|
1372
|
+
</AwCard>
|
|
1373
|
+
</div>
|
|
1374
|
+
</AwGrid>
|
|
1375
|
+
</AwPageSingle>
|
|
1376
|
+
</template>
|
|
1377
|
+
|
|
1378
|
+
<script>
|
|
1379
|
+
import Product from '~/models/Product'
|
|
1380
|
+
|
|
1381
|
+
export default {
|
|
1382
|
+
middleware: 'auth',
|
|
1383
|
+
|
|
1384
|
+
data() {
|
|
1385
|
+
return {
|
|
1386
|
+
product: new Product(
|
|
1387
|
+
{ id: this.$route.params.id },
|
|
1388
|
+
null,
|
|
1389
|
+
{ shop_uuid: this.$route.params.shop_uuid }
|
|
1390
|
+
)
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
|
|
1394
|
+
computed: {
|
|
1395
|
+
pageTitle() {
|
|
1396
|
+
return this.product.isNew() ? 'Create Product' : 'Edit Product'
|
|
1397
|
+
},
|
|
1398
|
+
|
|
1399
|
+
saveButton() {
|
|
1400
|
+
return {
|
|
1401
|
+
text: 'Save',
|
|
1402
|
+
loading: this.product.saving,
|
|
1403
|
+
color: 'accent'
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
|
|
1408
|
+
async mounted() {
|
|
1409
|
+
if (!this.product.isNew()) {
|
|
1410
|
+
try {
|
|
1411
|
+
await this.product.fetch()
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
this.$notify({
|
|
1414
|
+
title: 'Product not found',
|
|
1415
|
+
type: 'error'
|
|
1416
|
+
})
|
|
1417
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/products`)
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
|
|
1422
|
+
methods: {
|
|
1423
|
+
loadCategories(search) {
|
|
1424
|
+
const shopUuid = this.$route.params.shop_uuid
|
|
1425
|
+
return `/api/shops/${shopUuid}/categories?search=${search}`
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1428
|
+
async save() {
|
|
1429
|
+
try {
|
|
1430
|
+
await this.product.save()
|
|
1431
|
+
|
|
1432
|
+
this.$notify({
|
|
1433
|
+
title: `Product ${this.product.isNew() ? 'created' : 'updated'} successfully`,
|
|
1434
|
+
type: 'success'
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/products`)
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
this.$notify({
|
|
1440
|
+
title: 'Failed to save product',
|
|
1441
|
+
type: 'error'
|
|
1442
|
+
})
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
|
|
1446
|
+
async deleteProduct() {
|
|
1447
|
+
try {
|
|
1448
|
+
await this.$confirm({
|
|
1449
|
+
title: 'Delete Product',
|
|
1450
|
+
message: `Are you sure you want to delete "${this.product.name}"?`
|
|
1451
|
+
})
|
|
1452
|
+
|
|
1453
|
+
await this.product.delete()
|
|
1454
|
+
this.$notify({
|
|
1455
|
+
message: 'Product deleted successfully',
|
|
1456
|
+
type: 'success'
|
|
1457
|
+
})
|
|
1458
|
+
this.$router.push(`/${this.$route.params.shop_uuid}/products`)
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
if (this.$confirm.isCancel(error)) {
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
this.$notify({
|
|
1465
|
+
message: 'Failed to delete product',
|
|
1466
|
+
type: 'error'
|
|
1467
|
+
})
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
</script>
|
|
1473
|
+
|
|
1474
|
+
## Best Practices
|
|
1475
|
+
|
|
1476
|
+
### 1. Always Check isNew()
|
|
1477
|
+
|
|
1478
|
+
```javascript
|
|
1479
|
+
computed: {
|
|
1480
|
+
pageTitle() {
|
|
1481
|
+
return this.model.isNew() ? 'Create' : 'Edit'
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
### 2. Handle Fetch Errors
|
|
1487
|
+
|
|
1488
|
+
```javascript
|
|
1489
|
+
async mounted() {
|
|
1490
|
+
if (!this.model.isNew()) {
|
|
1491
|
+
try {
|
|
1492
|
+
await this.model.fetch()
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
// Redirect on error
|
|
1495
|
+
this.$router.push('/list')
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
### 3. Handle Save Errors
|
|
1502
|
+
|
|
1503
|
+
The `save()` method throws on validation errors, so no need to check `errors` manually:
|
|
1504
|
+
|
|
1505
|
+
```javascript
|
|
1506
|
+
try {
|
|
1507
|
+
await this.model.save()
|
|
1508
|
+
// Success path - only reached if save succeeds
|
|
1509
|
+
this.$notify({ title: 'Saved successfully', type: 'success' })
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
// Error path - handles both validation and network errors
|
|
1512
|
+
this.$notify({ title: 'Failed to save', type: 'error' })
|
|
1513
|
+
}
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
### 4. Confirm Destructive Actions
|
|
1517
|
+
|
|
1518
|
+
Always wrap `$confirm` in try-catch and check for cancellation:
|
|
1519
|
+
|
|
1520
|
+
```javascript
|
|
1521
|
+
async deleteItem() {
|
|
1522
|
+
try {
|
|
1523
|
+
await this.$confirm({
|
|
1524
|
+
title: 'Delete Item',
|
|
1525
|
+
message: `Are you sure you want to delete "${this.model.name}"?`
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
await this.model.delete()
|
|
1529
|
+
this.$notify({ title: 'Deleted successfully', type: 'success' })
|
|
1530
|
+
this.$router.push('/list')
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
if (this.$confirm.isCancel(error)) {
|
|
1533
|
+
return // User cancelled, do nothing
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
this.$notify({ title: 'Failed to delete', type: 'error' })
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
### 5. Provide User Feedback
|
|
1542
|
+
|
|
1543
|
+
```javascript
|
|
1544
|
+
this.$notify({
|
|
1545
|
+
message: 'Saved successfully',
|
|
1546
|
+
type: 'success'
|
|
1547
|
+
})
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
## See Also
|
|
1551
|
+
|
|
1552
|
+
- [List Pages](./list-pages.md) - Table-based list pages
|
|
1553
|
+
- [Dashboard Pages](./dashboard-pages.md) - Metrics and overview pages
|
|
1554
|
+
- [AwPageSingle](../../components/pages/aw-page-single.md) - Page component reference
|
|
1555
|
+
- [Forms Guide](../forms-guide.md) - Form patterns and validation
|
|
1556
|
+
- [Error Handling Guide](../error-handling.md) - Error handling patterns
|