@drax/crud-vue 3.15.0 → 3.17.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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.15.0",
6
+ "version": "3.17.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "module": "./src/index.ts",
@@ -26,8 +26,8 @@
26
26
  "dependencies": {
27
27
  "@drax/common-front": "^3.11.0",
28
28
  "@drax/crud-front": "^3.11.0",
29
- "@drax/crud-share": "^3.14.0",
30
- "@drax/media-vue": "^3.15.0"
29
+ "@drax/crud-share": "^3.17.0",
30
+ "@drax/media-vue": "^3.17.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "pinia": "^3.0.4",
@@ -50,5 +50,5 @@
50
50
  "vue-tsc": "^3.2.4",
51
51
  "vuetify": "^3.11.8"
52
52
  },
53
- "gitHead": "a3bd3419f580b111b26da9e6be2e1cc4c75a056e"
53
+ "gitHead": "d8a056b087d67157bc8c2e684b7e6696399d06a4"
54
54
  }
@@ -1,13 +1,16 @@
1
1
  <script setup lang="ts">
2
- import {computed, onBeforeMount, type PropType} from "vue";
2
+ import {computed, onBeforeMount, ref, watch, type PropType} from "vue";
3
3
  import type {IEntityCrud} from "@drax/crud-share";
4
4
  import CrudListTable from "./CrudListTable.vue";
5
5
  import CrudListGallery from "./CrudListGallery.vue";
6
6
  import CrudForm from "./CrudForm.vue";
7
7
  import CrudNotify from "./CrudNotify.vue";
8
8
  import CrudDialog from "./CrudDialog.vue";
9
+ import CrudAiButton from "./buttons/CrudAiButton.vue";
10
+ import CrudAi from "./CrudAi.vue";
9
11
  import {useCrud} from "../composables/UseCrud";
10
12
  import {useDisplay} from 'vuetify'
13
+ import {useAuth} from "@drax/identity-vue";
11
14
 
12
15
  const {entity} = defineProps({
13
16
  entity: {type: Object as PropType<IEntityCrud>, required: true},
@@ -19,6 +22,8 @@ const {
19
22
  prepareFilters, prepareSort, form
20
23
  } = useCrud(entity);
21
24
 
25
+ const {hasPermission} = useAuth()
26
+
22
27
  onBeforeMount(() => {
23
28
  resetCrudStore()
24
29
  prepareFilters()
@@ -26,6 +31,7 @@ onBeforeMount(() => {
26
31
  })
27
32
 
28
33
  const emit = defineEmits(['created', 'updated', 'deleted', 'viewed', 'canceled'])
34
+ const aiExpanded = ref(false)
29
35
 
30
36
  const listComponent = computed(() => {
31
37
  const listMode = entity?.listMode
@@ -43,6 +49,16 @@ const listComponent = computed(() => {
43
49
 
44
50
  const {xs} = useDisplay()
45
51
 
52
+ function applyAiSuggestions(values: Record<string, any>) {
53
+ form.value = values
54
+ }
55
+
56
+ watch(dialog, (value) => {
57
+ if (!value) {
58
+ aiExpanded.value = false
59
+ }
60
+ })
61
+
46
62
 
47
63
  </script>
48
64
 
@@ -127,7 +143,21 @@ const {xs} = useDisplay()
127
143
  :entity="entity"
128
144
  :operation="operation"
129
145
  >
146
+ <template #toolbar-actions>
147
+ <crud-ai-button
148
+ v-if="entity.isAiAssistable && ['create', 'edit'].includes(operation) && hasPermission('ai:promptCrud')"
149
+ :entity="entity"
150
+ v-model="aiExpanded"
151
+ />
152
+ </template>
153
+
130
154
  <slot name="tools">
155
+ <crud-ai
156
+ v-if="entity.isAiAssistable && ['create', 'edit'].includes(operation) && hasPermission('ai:promptCrud')"
157
+ :entity="entity"
158
+ v-model="aiExpanded"
159
+ @apply="applyAiSuggestions"
160
+ />
131
161
 
132
162
  </slot>
133
163
 
@@ -0,0 +1,441 @@
1
+ <script setup lang="ts">
2
+ import {computed, ref, watch} from "vue";
3
+ import type {PropType} from "vue";
4
+ import {useI18n} from "vue-i18n";
5
+ import type {IEntityCrud, IEntityCrudField} from "@drax/crud-share";
6
+ import {HttpRestClientFactory} from "@drax/common-front";
7
+ import {useCrud} from "../composables/UseCrud";
8
+ import {useAuth} from "@drax/identity-vue";
9
+
10
+ type CrudAiResponse = {
11
+ message?: string | null
12
+ suggestions?: Record<string, any>
13
+ warnings?: string[]
14
+ }
15
+
16
+ const expanded = defineModel<boolean>({default: false})
17
+
18
+ const emit = defineEmits<{
19
+ apply: [form: Record<string, any>]
20
+ }>()
21
+
22
+ const {entity} = defineProps({
23
+ entity: {type: Object as PropType<IEntityCrud>, required: true},
24
+ })
25
+
26
+ const {t, te} = useI18n()
27
+ const {hasPermission} = useAuth()
28
+ const {form, operation} = useCrud(entity)
29
+ const httpClient = HttpRestClientFactory.getInstance()
30
+
31
+ const prompt = ref("")
32
+ const loading = ref(false)
33
+ const error = ref("")
34
+ const response = ref<CrudAiResponse | null>(null)
35
+
36
+ function cloneValue<T>(value: T): T {
37
+ if (value === undefined) {
38
+ return value
39
+ }
40
+
41
+ if (typeof structuredClone === 'function') {
42
+ try {
43
+ return structuredClone(value)
44
+ } catch (_error) {
45
+ }
46
+ }
47
+
48
+ if (Array.isArray(value)) {
49
+ return value.map((item) => cloneValue(item)) as T
50
+ }
51
+
52
+ if (value instanceof Date) {
53
+ return new Date(value.getTime()) as T
54
+ }
55
+
56
+ if (value && typeof value === 'object') {
57
+ const objectValue = value as Record<string, any>
58
+ const plainObject = Object.keys(objectValue).reduce((acc, key) => {
59
+ const item = objectValue[key]
60
+
61
+ if (typeof item === 'function') {
62
+ return acc
63
+ }
64
+
65
+ try {
66
+ acc[key] = cloneValue(item)
67
+ } catch (_error) {
68
+ acc[key] = item
69
+ }
70
+
71
+ return acc
72
+ }, {} as Record<string, any>)
73
+
74
+ return plainObject as T
75
+ }
76
+
77
+ return value
78
+ }
79
+
80
+ function normalizeField(field: IEntityCrudField): Record<string, any> {
81
+ return {
82
+ name: field.name,
83
+ type: field.type,
84
+ label: field.label,
85
+ hint: field.hint,
86
+ placeholder: field.placeholder,
87
+ readonly: field.readonly,
88
+ default: field.default,
89
+ enum: field.enum,
90
+ items: field.items?.map(item => ({
91
+ title: item.title,
92
+ value: item.value,
93
+ })),
94
+ ref: field.ref,
95
+ refDisplay: field.refDisplay,
96
+ objectFields: field.objectFields?.map(normalizeField),
97
+ }
98
+ }
99
+
100
+ function parseStructuredValue(field: IEntityCrudField, value: any) {
101
+ if (value === null || value === undefined) {
102
+ return value
103
+ }
104
+
105
+ const jsonEncodedTypes = ['record', 'array.object', 'array.record', 'array.fullFile', 'file', 'fullFile']
106
+
107
+ if (field.type === 'object' && field.objectFields && typeof value === 'object') {
108
+ return value
109
+ }
110
+
111
+ if (field.type === 'object' && typeof value === 'string') {
112
+ try {
113
+ return JSON.parse(value)
114
+ } catch (_error) {
115
+ return value
116
+ }
117
+ }
118
+
119
+ if (jsonEncodedTypes.includes(field.type) && typeof value === 'string') {
120
+ try {
121
+ return JSON.parse(value)
122
+ } catch (_error) {
123
+ return value
124
+ }
125
+ }
126
+
127
+ return value
128
+ }
129
+
130
+ const editableFields = computed(() => {
131
+ const sourceFields = operation.value === 'edit'
132
+ ? entity.updateFields
133
+ : entity.createFields
134
+
135
+ return sourceFields.filter((field: IEntityCrudField) => {
136
+ if (field.readonly) {
137
+ return false
138
+ }
139
+
140
+ if (field.permission && !hasPermission(field.permission)) {
141
+ return false
142
+ }
143
+
144
+ return !['id', 'password', 'file', 'fullFile', 'array.fullFile'].includes(field.type)
145
+ })
146
+ })
147
+
148
+ const allowedFieldNames = computed(() => {
149
+ return new Set(editableFields.value.map(field => field.name))
150
+ })
151
+
152
+ const currentForm = computed(() => {
153
+ return cloneValue(form.value || entity.form || {})
154
+ })
155
+
156
+ const sanitizedSuggestions = computed(() => {
157
+ const suggestions = response.value?.suggestions || {}
158
+
159
+ return Object.keys(suggestions).reduce((acc, key) => {
160
+ if (allowedFieldNames.value.has(key)) {
161
+ const field = editableFields.value.find(item => item.name === key)
162
+ acc[key] = field ? parseStructuredValue(field, suggestions[key]) : suggestions[key]
163
+ }
164
+ return acc
165
+ }, {} as Record<string, any>)
166
+ })
167
+
168
+ function isEqualValue(left: any, right: any) {
169
+ return JSON.stringify(left) === JSON.stringify(right)
170
+ }
171
+
172
+ function deepMerge(target: any, source: any): any {
173
+ if (Array.isArray(source)) {
174
+ return cloneValue(source)
175
+ }
176
+
177
+ if (source && typeof source === 'object') {
178
+ const base = target && typeof target === 'object' && !Array.isArray(target)
179
+ ? cloneValue(target)
180
+ : {}
181
+
182
+ Object.keys(source).forEach((key) => {
183
+ base[key] = deepMerge(base[key], source[key])
184
+ })
185
+
186
+ return base
187
+ }
188
+
189
+ return source
190
+ }
191
+
192
+ const previewForm = computed(() => {
193
+ return deepMerge(currentForm.value, sanitizedSuggestions.value)
194
+ })
195
+
196
+ const changedEntries = computed(() => {
197
+ return editableFields.value
198
+ .map((field) => {
199
+ const currentValue = currentForm.value[field.name]
200
+ const suggestedValue = previewForm.value[field.name]
201
+
202
+ if (isEqualValue(currentValue, suggestedValue)) {
203
+ return null
204
+ }
205
+
206
+ return {
207
+ field,
208
+ currentValue,
209
+ suggestedValue,
210
+ }
211
+ })
212
+ .filter(Boolean) as Array<{
213
+ field: IEntityCrudField
214
+ currentValue: any
215
+ suggestedValue: any
216
+ }>
217
+ })
218
+
219
+ const canSubmit = computed(() => {
220
+ return prompt.value.trim().length > 0 && editableFields.value.length > 0 && !loading.value
221
+ })
222
+
223
+ const showCurrentValues = computed(() => {
224
+ return operation.value === 'edit'
225
+ })
226
+
227
+ const subtitle = computed(() => {
228
+ const entityName = te(`${entity.name.toLowerCase()}.entity`)
229
+ ? t(`${entity.name.toLowerCase()}.entity`)
230
+ : entity.name
231
+
232
+ return operation.value === 'edit'
233
+ ? `Editar ${entityName} con ayuda de IA`
234
+ : `Completar ${entityName} con ayuda de IA`
235
+ })
236
+
237
+ function formatValue(value: any) {
238
+ if (value === undefined || value === null || value === '') {
239
+ return '-'
240
+ }
241
+
242
+ if (typeof value === 'object') {
243
+ return JSON.stringify(value, null, 2)
244
+ }
245
+
246
+ return String(value)
247
+ }
248
+
249
+ async function generateSuggestions() {
250
+ if (!canSubmit.value) {
251
+ return
252
+ }
253
+
254
+ loading.value = true
255
+ error.value = ""
256
+ response.value = null
257
+
258
+ try {
259
+ response.value = await httpClient.post('/api/ai/prompt/crud', {
260
+ prompt: prompt.value.trim(),
261
+ operation: operation.value === 'edit' ? 'edit' : 'create',
262
+ entity: {
263
+ name: entity.name,
264
+ identifier: entity.identifier,
265
+ },
266
+ currentValues: currentForm.value,
267
+ fields: editableFields.value.map(normalizeField),
268
+ }, {}) as CrudAiResponse
269
+ } catch (e: any) {
270
+ error.value = e?.message || 'No se pudo obtener una sugerencia de IA'
271
+ response.value = null
272
+ } finally {
273
+ loading.value = false
274
+ }
275
+ }
276
+
277
+ function applySuggestions() {
278
+ emit('apply', previewForm.value)
279
+ expanded.value = false
280
+ }
281
+
282
+ function resetPanelState() {
283
+ prompt.value = ""
284
+ response.value = null
285
+ error.value = ""
286
+ loading.value = false
287
+ }
288
+
289
+ watch(expanded, (value) => {
290
+ if (!value) {
291
+ resetPanelState()
292
+ }
293
+ })
294
+
295
+ watch(prompt, () => {
296
+ response.value = null
297
+ error.value = ""
298
+ })
299
+ </script>
300
+
301
+ <template>
302
+ <v-expand-transition>
303
+ <div v-if="expanded" class="mb-4">
304
+ <v-card variant="tonal" class="crud-ai-panel">
305
+ <v-card-text>
306
+ <div class="d-flex align-center mb-4">
307
+ <div class="text-subtitle-1">{{ subtitle }}</div>
308
+ <v-spacer />
309
+ <v-btn icon="mdi-close" variant="text" @click="expanded = false" />
310
+ </div>
311
+
312
+ <v-alert
313
+ v-if="error"
314
+ type="error"
315
+ variant="tonal"
316
+ class="mb-4"
317
+ :text="error"
318
+ />
319
+
320
+ <v-alert
321
+ v-if="editableFields.length === 0"
322
+ type="warning"
323
+ variant="tonal"
324
+ class="mb-4"
325
+ :text="te('ai.noEditableFields') ? t('ai.noEditableFields') : 'No hay campos editables disponibles para asistir con IA.'"
326
+ />
327
+
328
+ <v-textarea
329
+ v-model="prompt"
330
+ rows="3"
331
+ auto-grow
332
+ variant="outlined"
333
+ :label="te('ai.prompt') ? t('ai.prompt') : 'Prompt'"
334
+ :placeholder="te('ai.promptPlaceholder') ? t('ai.promptPlaceholder') : 'Ejemplo: completá este formulario para un producto premium orientado a pequeñas empresas.'"
335
+ />
336
+
337
+ <div class="d-flex justify-end mt-2">
338
+ <v-btn
339
+ color="primary"
340
+ variant="flat"
341
+ :loading="loading"
342
+ :disabled="!canSubmit"
343
+ @click="generateSuggestions"
344
+ >
345
+ {{ te('action.generate') ? t('action.generate') : 'Generar sugerencia' }}
346
+ </v-btn>
347
+ </div>
348
+
349
+ <v-card v-if="response" variant="outlined" class="mt-6">
350
+ <v-card-title>
351
+ {{ te('ai.preview') ? t('ai.preview') : 'Vista previa' }}
352
+ </v-card-title>
353
+
354
+ <v-card-text>
355
+ <v-alert
356
+ v-if="response.message"
357
+ variant="tonal"
358
+ type="info"
359
+ class="mb-4"
360
+ :text="response.message"
361
+ />
362
+
363
+ <v-alert
364
+ v-if="changedEntries.length === 0"
365
+ variant="tonal"
366
+ type="info"
367
+ :text="te('ai.noChanges') ? t('ai.noChanges') : 'La IA no propuso cambios aplicables para este formulario.'"
368
+ />
369
+
370
+ <v-list v-else lines="three">
371
+ <v-list-item
372
+ v-for="entry in changedEntries"
373
+ :key="entry.field.name"
374
+ class="px-0"
375
+ >
376
+ <v-list-item-title>
377
+ {{ entry.field.label || entry.field.name }}
378
+ </v-list-item-title>
379
+ <div class="mt-1 preview-block">
380
+ <template v-if="showCurrentValues">
381
+ <div class="text-caption text-medium-emphasis">Actual</div>
382
+ <pre class="value-preview">{{ formatValue(entry.currentValue) }}</pre>
383
+ </template>
384
+ <div class="text-caption text-medium-emphasis mt-2">Sugerido</div>
385
+ <pre class="value-preview">{{ formatValue(entry.suggestedValue) }}</pre>
386
+ </div>
387
+ </v-list-item>
388
+ </v-list>
389
+
390
+ <div v-if="(response.warnings || []).length > 0" class="mt-4">
391
+ <v-alert
392
+ v-for="warning in response.warnings || []"
393
+ :key="warning"
394
+ variant="tonal"
395
+ type="warning"
396
+ class="mb-3"
397
+ :text="warning"
398
+ />
399
+ </div>
400
+ </v-card-text>
401
+ </v-card>
402
+ </v-card-text>
403
+
404
+ <v-card-actions v-if="response">
405
+ <v-spacer />
406
+ <v-btn variant="text" @click="expanded = false">
407
+ {{ t('action.cancel') }}
408
+ </v-btn>
409
+ <v-btn
410
+ color="primary"
411
+ variant="flat"
412
+ :disabled="changedEntries.length === 0"
413
+ @click="applySuggestions"
414
+ >
415
+ {{ te('action.apply') ? t('action.apply') : 'Aplicar' }}
416
+ </v-btn>
417
+ </v-card-actions>
418
+ </v-card>
419
+ </div>
420
+ </v-expand-transition>
421
+ </template>
422
+
423
+ <style scoped>
424
+ .crud-ai-panel {
425
+ border: 1px solid rgba(0, 0, 0, 0.08);
426
+ }
427
+
428
+ .value-preview {
429
+ margin: 0;
430
+ padding: 8px 12px;
431
+ white-space: pre-wrap;
432
+ word-break: break-word;
433
+ border-radius: 8px;
434
+ background: rgba(0, 0, 0, 0.04);
435
+ font-family: inherit;
436
+ }
437
+
438
+ .preview-block {
439
+ white-space: normal;
440
+ }
441
+ </style>
@@ -41,6 +41,7 @@ const title = computed(() => {
41
41
  <v-toolbar>
42
42
  <v-toolbar-title>{{title}}</v-toolbar-title>
43
43
  <v-spacer></v-spacer>
44
+ <slot name="toolbar-actions"></slot>
44
45
  <v-btn icon @click="dialog = false"><v-icon>mdi-close</v-icon></v-btn>
45
46
  </v-toolbar>
46
47
  <v-card-text>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import type {PropType} from "vue";
3
+ import {computed} from "vue";
4
+ import {useI18n} from "vue-i18n";
5
+ import type {IEntityCrud} from "@drax/crud-share";
6
+
7
+ const expanded = defineModel<boolean>({default: false})
8
+
9
+ defineProps({
10
+ entity: {type: Object as PropType<IEntityCrud>, required: true},
11
+ })
12
+
13
+ const {t, te} = useI18n()
14
+
15
+ const tooltip = computed(() => {
16
+ return te('action.aiAssist') ? t('action.aiAssist') : 'Asistencia IA'
17
+ })
18
+
19
+ function toggleExpanded() {
20
+ expanded.value = !expanded.value
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <v-tooltip location="top">
26
+ <template v-slot:activator="{ props }">
27
+ <v-btn
28
+ v-bind="{ ...$attrs, ...props }"
29
+ icon="mdi-robot-outline"
30
+ class="mr-1"
31
+ variant="text"
32
+ @click="toggleExpanded"
33
+ />
34
+ </template>
35
+ {{ tooltip }}
36
+ </v-tooltip>
37
+ </template>
38
+
39
+ <style scoped>
40
+
41
+ </style>
@@ -342,6 +342,9 @@ class EntityCrud implements IEntityCrud {
342
342
  return 'solo-filled'
343
343
  }
344
344
 
345
+ get isAiAssistable(): boolean {
346
+ return false
347
+ }
345
348
 
346
349
  }
347
350