@innertia-solutions/innertia-nuxt 0.1.12 → 0.1.13
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/components/Attention.vue +269 -0
- package/components/Chart/index.vue +193 -0
- package/components/Container.vue +116 -0
- package/components/CopyButton.vue +98 -0
- package/components/Files/Card.vue +215 -0
- package/components/Files/Preview.vue +675 -0
- package/components/Files/Upload.vue +408 -0
- package/components/Files/UploadModal.vue +83 -0
- package/components/Forms/InputPin.vue +162 -0
- package/components/InfoToolbar.vue +54 -0
- package/components/Kanban/Standard.vue +588 -0
- package/components/Loader.vue +358 -0
- package/components/Modal/Base.vue +6 -0
- package/components/Modal/Confirm.vue +89 -0
- package/components/Modal/DeleteConfirm.vue +14 -31
- package/components/Modal/Form.vue +76 -0
- package/components/Modal/Host.vue +58 -0
- package/components/Modal/Prompt.vue +74 -0
- package/components/Modal.vue +119 -34
- package/components/Table/Standard.vue +198 -25
- package/components/Table/index.vue +169 -369
- package/components/Table/views/ViewCards.vue +74 -0
- package/components/Table/views/ViewDatabase.vue +236 -0
- package/components/Table/views/ViewTable.vue +299 -0
- package/components/Toast/Alert.vue +16 -111
- package/components/Toast/Container.vue +4 -29
- package/components/Toast/Notification.vue +8 -43
- package/components/Toast/Process.vue +8 -86
- package/components/Toast/Stack.vue +59 -0
- package/components/Toast/Standard.vue +115 -0
- package/components/Tree/Standard.vue +49 -4
- package/components/Workflow/Board.vue +356 -0
- package/components/Workflow/Builder.vue +454 -0
- package/components/Workflow/Form.vue +526 -0
- package/components/Workflow/Viewer.vue +225 -0
- package/components/Workflow/edges/TransitionEdge.vue +84 -0
- package/components/Workflow/nodes/StepNode.vue +88 -0
- package/composables/useClipboard.js +79 -0
- package/composables/useConfetti.js +194 -0
- package/composables/useFile.js +189 -0
- package/composables/useModal.js +71 -0
- package/composables/useToast.js +38 -65
- package/composables/useWorkflow/form.js +162 -0
- package/composables/useWorkflow/index.js +18 -0
- package/composables/useWorkflow/view.js +220 -0
- package/composables/useWorkflowLayout.js +162 -0
- package/css/tokens.css +10 -10
- package/nuxt.config.ts +1 -1
- package/package.json +13 -8
- package/stores/modal.js +48 -0
- package/stores/toast.js +156 -117
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IconFile, IconPhoto, IconVideo, IconMusic, IconFileZip,
|
|
3
|
+
IconFileTypePdf, IconFileTypeXls, IconFileTypeCsv,
|
|
4
|
+
IconFileTypeDocx, IconFileTypePpt, IconFileTypeTxt,
|
|
5
|
+
} from '@tabler/icons-vue'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useFile — API unificada para trabajar con archivos del backend innertia-laravel.
|
|
9
|
+
*
|
|
10
|
+
* Cubre:
|
|
11
|
+
* - upload(endpoint, file|files, { field, onProgress, extraData, signal })
|
|
12
|
+
* - download(fileId, filename?) — trigger descarga programática
|
|
13
|
+
* - view(fileId) — abrir inline en otra pestaña
|
|
14
|
+
* - viewUrl(fileId) / downloadUrl(fileId) — URLs absolutas a las rutas de innertia-laravel
|
|
15
|
+
* - formatSize(bytes) — helper humano-legible
|
|
16
|
+
* - iconFor(mimeType) — devuelve el componente Tabler para el mime
|
|
17
|
+
*
|
|
18
|
+
* Autenticación: los interceptores de useApi (auth Bearer, X-Tenant, X-Organization)
|
|
19
|
+
* se aplican automáticamente al XHR de upload.
|
|
20
|
+
*/
|
|
21
|
+
export function useFile() {
|
|
22
|
+
const config = useRuntimeConfig()
|
|
23
|
+
const baseUrl = config.public.apiBaseUrl || '/api'
|
|
24
|
+
|
|
25
|
+
// useRequestInterceptors es el mismo que usa useApi() — comparte auth/tenant/org headers.
|
|
26
|
+
const { run } = useRequestInterceptors()
|
|
27
|
+
|
|
28
|
+
// ── URLs ───────────────────────────────────────────────────────────────────
|
|
29
|
+
/** GET /files/{id} — inline view (respeta permisos del backend). */
|
|
30
|
+
const viewUrl = (fileId) => `${baseUrl}/files/${fileId}`
|
|
31
|
+
/** GET /files/{id}/download — force download (respeta permisos). */
|
|
32
|
+
const downloadUrl = (fileId) => `${baseUrl}/files/${fileId}/download`
|
|
33
|
+
|
|
34
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
35
|
+
const formatSize = (bytes) => {
|
|
36
|
+
if (bytes == null) return ''
|
|
37
|
+
if (bytes < 1024) return `${bytes} B`
|
|
38
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
39
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
40
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Devuelve el componente Tabler para un mime type. */
|
|
44
|
+
const iconFor = (mimeType) => {
|
|
45
|
+
if (!mimeType) return IconFile
|
|
46
|
+
if (mimeType.startsWith('image/')) return IconPhoto
|
|
47
|
+
if (mimeType.startsWith('video/')) return IconVideo
|
|
48
|
+
if (mimeType.startsWith('audio/')) return IconMusic
|
|
49
|
+
if (mimeType === 'application/pdf') return IconFileTypePdf
|
|
50
|
+
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return IconFileTypeXls
|
|
51
|
+
if (mimeType === 'text/csv') return IconFileTypeCsv
|
|
52
|
+
if (mimeType.includes('word') || mimeType.includes('document')) return IconFileTypeDocx
|
|
53
|
+
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return IconFileTypePpt
|
|
54
|
+
if (mimeType.startsWith('text/')) return IconFileTypeTxt
|
|
55
|
+
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) return IconFileZip
|
|
56
|
+
return IconFile
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── View / Download ────────────────────────────────────────────────────────
|
|
60
|
+
/** Abre el file inline en una nueva pestaña (usa la auth de cookie). */
|
|
61
|
+
const view = (fileId) => {
|
|
62
|
+
if (typeof window === 'undefined') return
|
|
63
|
+
window.open(viewUrl(fileId), '_blank', 'noopener,noreferrer')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Trigger descarga programática. Funciona con archivos auth/restricted
|
|
68
|
+
* porque el browser envía cookies automáticamente.
|
|
69
|
+
*/
|
|
70
|
+
const download = (fileId, filename) => {
|
|
71
|
+
if (typeof window === 'undefined') return
|
|
72
|
+
const a = document.createElement('a')
|
|
73
|
+
a.href = downloadUrl(fileId)
|
|
74
|
+
if (filename) a.download = filename
|
|
75
|
+
document.body.appendChild(a)
|
|
76
|
+
a.click()
|
|
77
|
+
document.body.removeChild(a)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Upload (con progress vía XHR) ──────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* Upload un archivo (o lista) a un endpoint del producto. El endpoint
|
|
83
|
+
* recibe `multipart/form-data` con el campo `field` y debe persistir el
|
|
84
|
+
* archivo (típicamente `File::fromRequest($request, 'file')` del lado Laravel).
|
|
85
|
+
*
|
|
86
|
+
* Para varios archivos pasa un array — el campo se envía como `field[]`.
|
|
87
|
+
* Devuelve el JSON parseado del response (típicamente el File record creado).
|
|
88
|
+
*
|
|
89
|
+
* Opciones:
|
|
90
|
+
* field — nombre del campo. Default 'file'.
|
|
91
|
+
* onProgress — callback(percent: number 0-100)
|
|
92
|
+
* extraData — campos adicionales para incluir en el FormData
|
|
93
|
+
* signal — AbortSignal para cancelar
|
|
94
|
+
*/
|
|
95
|
+
const upload = (endpoint, file, options = {}) => {
|
|
96
|
+
const field = options.field ?? 'file'
|
|
97
|
+
const files = Array.isArray(file) ? file : [file]
|
|
98
|
+
const formData = new FormData()
|
|
99
|
+
const fieldName = files.length > 1 ? `${field}[]` : field
|
|
100
|
+
|
|
101
|
+
for (const f of files) formData.append(fieldName, f)
|
|
102
|
+
|
|
103
|
+
if (options.extraData) {
|
|
104
|
+
for (const [k, v] of Object.entries(options.extraData)) {
|
|
105
|
+
if (v == null) continue
|
|
106
|
+
formData.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recolectar headers vía los interceptores (auth, tenant, org).
|
|
111
|
+
// NO incluimos Content-Type — el browser lo setea con el boundary correcto.
|
|
112
|
+
const headers = {
|
|
113
|
+
'Accept': 'application/json',
|
|
114
|
+
'X-Innertia-Source': import.meta.server ? 'ssr' : 'client',
|
|
115
|
+
}
|
|
116
|
+
run(headers, {})
|
|
117
|
+
delete headers['Content-Type']
|
|
118
|
+
|
|
119
|
+
const cleanPath = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint
|
|
120
|
+
const url = `${baseUrl}/${cleanPath}`
|
|
121
|
+
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const xhr = new XMLHttpRequest()
|
|
124
|
+
xhr.open('POST', url)
|
|
125
|
+
xhr.withCredentials = true
|
|
126
|
+
|
|
127
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
128
|
+
try { xhr.setRequestHeader(k, v) } catch (_) { /* algunos headers son read-only */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
xhr.upload.addEventListener('progress', (e) => {
|
|
132
|
+
if (e.lengthComputable && options.onProgress) {
|
|
133
|
+
options.onProgress(Math.round((e.loaded / e.total) * 100), e.loaded, e.total)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Cuando el upload del cliente terminó (100%) pero el server aún no
|
|
138
|
+
// respondió — útil para mostrar estado "Procesando…" en la UI.
|
|
139
|
+
xhr.upload.addEventListener('loadend', () => {
|
|
140
|
+
options.onUploaded?.()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
xhr.addEventListener('load', () => {
|
|
144
|
+
const status = xhr.status
|
|
145
|
+
const ctype = xhr.getResponseHeader('content-type') ?? ''
|
|
146
|
+
const body = ctype.includes('application/json')
|
|
147
|
+
? safeJsonParse(xhr.responseText)
|
|
148
|
+
: xhr.responseText
|
|
149
|
+
|
|
150
|
+
if (status >= 200 && status < 300) {
|
|
151
|
+
resolve(body)
|
|
152
|
+
} else {
|
|
153
|
+
const err = new Error(`Upload failed (${status})`)
|
|
154
|
+
err.status = status
|
|
155
|
+
err.data = body
|
|
156
|
+
reject(err)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
|
161
|
+
xhr.addEventListener('abort', () => reject(Object.assign(new Error('Upload aborted'), { aborted: true })))
|
|
162
|
+
xhr.addEventListener('timeout', () => reject(new Error('Upload timeout')))
|
|
163
|
+
|
|
164
|
+
if (options.signal) {
|
|
165
|
+
if (options.signal.aborted) {
|
|
166
|
+
xhr.abort()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
options.signal.addEventListener('abort', () => xhr.abort())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
xhr.send(formData)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
upload,
|
|
178
|
+
download,
|
|
179
|
+
view,
|
|
180
|
+
viewUrl,
|
|
181
|
+
downloadUrl,
|
|
182
|
+
formatSize,
|
|
183
|
+
iconFor,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function safeJsonParse(text) {
|
|
188
|
+
try { return JSON.parse(text) } catch { return text }
|
|
189
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// composables/useModal.js
|
|
2
|
+
import { useModalStore } from '../stores/modal'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useModal() — API promise-based para modales programáticos.
|
|
6
|
+
*
|
|
7
|
+
* Requiere <ModalHost /> montado una vez en el layout root.
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* const { confirm, alert, prompt, open } = useModal()
|
|
11
|
+
*
|
|
12
|
+
* if (await confirm({ severity: 'danger', message: '¿Eliminar?' })) {
|
|
13
|
+
* // user confirmed
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* await alert({ severity: 'success', message: 'Listo' })
|
|
17
|
+
*
|
|
18
|
+
* const name = await prompt({ title: 'Nombre', placeholder: 'Tu nombre' })
|
|
19
|
+
*
|
|
20
|
+
* const result = await open(MyCustomModal, { foo: 'bar' })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function useModal() {
|
|
24
|
+
const store = useModalStore()
|
|
25
|
+
|
|
26
|
+
/** Confirmación. Resuelve true/false. */
|
|
27
|
+
const confirm = (opts = {}) => store.open({
|
|
28
|
+
kind: 'confirm',
|
|
29
|
+
props: {
|
|
30
|
+
severity: opts.severity ?? 'question',
|
|
31
|
+
title: opts.title ?? '',
|
|
32
|
+
message: opts.message ?? '',
|
|
33
|
+
confirmText: opts.confirmText ?? 'Confirmar',
|
|
34
|
+
cancelText: opts.cancelText ?? 'Cancelar',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Alert simple — solo botón OK. Resuelve undefined. */
|
|
39
|
+
const alert = (opts = {}) => store.open({
|
|
40
|
+
kind: 'alert',
|
|
41
|
+
props: {
|
|
42
|
+
severity: opts.severity ?? 'info',
|
|
43
|
+
title: opts.title ?? '',
|
|
44
|
+
message: opts.message ?? '',
|
|
45
|
+
confirmText: opts.confirmText ?? 'Entendido',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** Prompt — pide un string. Resuelve string|null (null si cancela). */
|
|
50
|
+
const prompt = (opts = {}) => store.open({
|
|
51
|
+
kind: 'prompt',
|
|
52
|
+
props: {
|
|
53
|
+
title: opts.title ?? 'Ingresar valor',
|
|
54
|
+
message: opts.message ?? '',
|
|
55
|
+
placeholder: opts.placeholder ?? '',
|
|
56
|
+
defaultValue: opts.defaultValue ?? '',
|
|
57
|
+
confirmText: opts.confirmText ?? 'Aceptar',
|
|
58
|
+
cancelText: opts.cancelText ?? 'Cancelar',
|
|
59
|
+
required: opts.required ?? false,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** Modal custom — montá tu propio componente. Resuelve con lo que pase a $close(value). */
|
|
64
|
+
const open = (component, props = {}) => store.open({
|
|
65
|
+
kind: 'component',
|
|
66
|
+
component,
|
|
67
|
+
props,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return { confirm, alert, prompt, open }
|
|
71
|
+
}
|
package/composables/useToast.js
CHANGED
|
@@ -1,69 +1,42 @@
|
|
|
1
|
-
//
|
|
1
|
+
// composables/useToast.js
|
|
2
2
|
import { useToastStore } from '../stores/toast'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* useToast() — API limpia para mostrar toasts.
|
|
6
|
+
*
|
|
7
|
+
* ```js
|
|
8
|
+
* const toast = useToast()
|
|
9
|
+
*
|
|
10
|
+
* toast.success('Guardado')
|
|
11
|
+
* toast.error('Algo falló', { title: 'Error', duration: 8000 })
|
|
12
|
+
* toast.warning('Atención')
|
|
13
|
+
* toast.info('Tip')
|
|
14
|
+
*
|
|
15
|
+
* toast.notify({ title: 'Nueva mención', message: 'Te etiquetaron', action: { label: 'Ver', onClick: () => {} } })
|
|
16
|
+
*
|
|
17
|
+
* const id = toast.process('Subiendo archivo…', { position: 'bottom-right' })
|
|
18
|
+
* toast.updateProgress(id, 40, '40%')
|
|
19
|
+
* toast.completeProcess(id, 'Listo')
|
|
20
|
+
*
|
|
21
|
+
* toast.dismiss(id)
|
|
22
|
+
* toast.clear() // todos
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
4
25
|
export function useToast() {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type: 'alert',
|
|
22
|
-
message,
|
|
23
|
-
duration: 3000,
|
|
24
|
-
...config
|
|
25
|
-
}),
|
|
26
|
-
|
|
27
|
-
error: (message, config = {}) => toast.error({
|
|
28
|
-
type: 'alert',
|
|
29
|
-
message,
|
|
30
|
-
duration: 5000,
|
|
31
|
-
...config
|
|
32
|
-
}),
|
|
33
|
-
|
|
34
|
-
warning: (message, config = {}) => toast.show({
|
|
35
|
-
type: 'alert',
|
|
36
|
-
severity: 'warning',
|
|
37
|
-
icon: 'ti ti-alert-triangle',
|
|
38
|
-
title: 'Warning',
|
|
39
|
-
message,
|
|
40
|
-
duration: 4000,
|
|
41
|
-
...config
|
|
42
|
-
}),
|
|
43
|
-
|
|
44
|
-
info: (message, config = {}) => toast.info({
|
|
45
|
-
type: 'alert',
|
|
46
|
-
message,
|
|
47
|
-
duration: 3000,
|
|
48
|
-
...config
|
|
49
|
-
})
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
notification: (title, message, config = {}) => toast.show({
|
|
53
|
-
type: 'notification',
|
|
54
|
-
title,
|
|
55
|
-
message,
|
|
56
|
-
duration: 0, // Las notificaciones no se auto-dismiss por defecto
|
|
57
|
-
...config
|
|
58
|
-
}),
|
|
59
|
-
|
|
60
|
-
process: (title, config = {}) => toast.show({
|
|
61
|
-
type: 'process',
|
|
62
|
-
title,
|
|
63
|
-
progress: 0,
|
|
64
|
-
progressLabel: 'Iniciando...',
|
|
65
|
-
duration: 0, // Los procesos no se auto-dismiss
|
|
66
|
-
...config
|
|
67
|
-
})
|
|
68
|
-
}
|
|
26
|
+
const store = useToastStore()
|
|
27
|
+
return {
|
|
28
|
+
show: store.show.bind(store),
|
|
29
|
+
success: store.success.bind(store),
|
|
30
|
+
error: store.error.bind(store),
|
|
31
|
+
warning: store.warning.bind(store),
|
|
32
|
+
info: store.info.bind(store),
|
|
33
|
+
notify: store.notify.bind(store),
|
|
34
|
+
process: store.process.bind(store),
|
|
35
|
+
update: store.update.bind(store),
|
|
36
|
+
updateProgress: store.updateProgress.bind(store),
|
|
37
|
+
completeProcess: store.completeProcess.bind(store),
|
|
38
|
+
failProcess: store.failProcess.bind(store),
|
|
39
|
+
dismiss: store.dismiss.bind(store),
|
|
40
|
+
clear: store.clear.bind(store),
|
|
41
|
+
}
|
|
69
42
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ref, computed, watch } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useWorkflowForm — gestiona el estado de edición de una WorkflowDefinition.
|
|
5
|
+
*
|
|
6
|
+
* Responsabilidad: CRUD de steps y transitions sobre el JSON de configuración.
|
|
7
|
+
* No sabe nada de instancias, vistas ni API calls de runtime.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object|null} initial — definición inicial { steps:[], transitions:[] }
|
|
10
|
+
*/
|
|
11
|
+
export function useWorkflowForm(initial = null) {
|
|
12
|
+
const definition = ref(
|
|
13
|
+
initial
|
|
14
|
+
? JSON.parse(JSON.stringify(initial))
|
|
15
|
+
: { steps: [], transitions: [] }
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const isDirty = ref(false)
|
|
19
|
+
const saving = ref(false)
|
|
20
|
+
|
|
21
|
+
// Track changes — se activa después del primer tick para no marcar dirty en la carga
|
|
22
|
+
let _trackChanges = false
|
|
23
|
+
watch(definition, () => { if (_trackChanges) isDirty.value = true }, { deep: true })
|
|
24
|
+
setTimeout(() => { _trackChanges = true }, 0)
|
|
25
|
+
|
|
26
|
+
// ── Steps ──────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const addStep = (step) => {
|
|
29
|
+
if (!step.key || !step.label || !step.type) throw new Error('Step requiere key, label y type')
|
|
30
|
+
if (definition.value.steps.some(s => s.key === step.key)) throw new Error(`Step '${step.key}' ya existe`)
|
|
31
|
+
definition.value.steps.push({ ...step })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const updateStep = (key, patch) => {
|
|
35
|
+
const idx = definition.value.steps.findIndex(s => s.key === key)
|
|
36
|
+
if (idx === -1) return
|
|
37
|
+
const newKey = patch.key ?? key
|
|
38
|
+
definition.value.steps[idx] = { ...definition.value.steps[idx], ...patch }
|
|
39
|
+
// Si cambió el key, actualizar todas las transiciones que lo referencian
|
|
40
|
+
if (newKey !== key) {
|
|
41
|
+
definition.value.transitions = definition.value.transitions.map(t => ({
|
|
42
|
+
...t,
|
|
43
|
+
from: t.from === key ? newKey : t.from,
|
|
44
|
+
to: t.to === key ? newKey : t.to,
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deleteStep = (key) => {
|
|
50
|
+
definition.value.steps = definition.value.steps.filter(s => s.key !== key)
|
|
51
|
+
definition.value.transitions = definition.value.transitions.filter(t => t.from !== key && t.to !== key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reorderSteps = (fromIdx, toIdx) => {
|
|
55
|
+
const steps = [...definition.value.steps]
|
|
56
|
+
const [moved] = steps.splice(fromIdx, 1)
|
|
57
|
+
steps.splice(toIdx, 0, moved)
|
|
58
|
+
definition.value.steps = steps
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Transitions ────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const addTransition = (transition) => {
|
|
64
|
+
if (!transition.from || !transition.to) throw new Error('Transition requiere from y to')
|
|
65
|
+
const exists = definition.value.transitions.some(
|
|
66
|
+
t => t.from === transition.from && t.to === transition.to
|
|
67
|
+
)
|
|
68
|
+
if (exists) throw new Error(`Transición ${transition.from} → ${transition.to} ya existe`)
|
|
69
|
+
definition.value.transitions.push({
|
|
70
|
+
from: transition.from,
|
|
71
|
+
to: transition.to,
|
|
72
|
+
label: transition.label ?? '',
|
|
73
|
+
description: transition.description ?? '',
|
|
74
|
+
restrictions: transition.restrictions ?? [],
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const updateTransition = (from, to, patch) => {
|
|
79
|
+
const idx = definition.value.transitions.findIndex(t => t.from === from && t.to === to)
|
|
80
|
+
if (idx === -1) return
|
|
81
|
+
definition.value.transitions[idx] = { ...definition.value.transitions[idx], ...patch }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const deleteTransition = (from, to) => {
|
|
85
|
+
definition.value.transitions = definition.value.transitions.filter(
|
|
86
|
+
t => !(t.from === from && t.to === to)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Restrictions helpers ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
const addRestriction = (from, to, restriction) => {
|
|
93
|
+
const t = definition.value.transitions.find(t => t.from === from && t.to === to)
|
|
94
|
+
if (!t) return
|
|
95
|
+
t.restrictions = [...(t.restrictions ?? []), { type: 'role', message: '', ...restriction }]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const removeRestriction = (from, to, idx) => {
|
|
99
|
+
const t = definition.value.transitions.find(t => t.from === from && t.to === to)
|
|
100
|
+
if (!t) return
|
|
101
|
+
t.restrictions = t.restrictions.filter((_, i) => i !== idx)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Computed helpers ───────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const stepByKey = computed(() =>
|
|
107
|
+
Object.fromEntries((definition.value.steps ?? []).map(s => [s.key, s]))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const transitionsFrom = (key) =>
|
|
111
|
+
(definition.value.transitions ?? []).filter(t => t.from === key)
|
|
112
|
+
|
|
113
|
+
const transitionsTo = (key) =>
|
|
114
|
+
(definition.value.transitions ?? []).filter(t => t.to === key)
|
|
115
|
+
|
|
116
|
+
const isValid = computed(() => {
|
|
117
|
+
const steps = definition.value.steps ?? []
|
|
118
|
+
return (
|
|
119
|
+
steps.length >= 2 &&
|
|
120
|
+
steps.some(s => s.type === 'start') &&
|
|
121
|
+
steps.some(s => ['finished', 'cancelled'].includes(s.type))
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ── Reset / load ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const reset = (newDefinition = null) => {
|
|
128
|
+
definition.value = newDefinition
|
|
129
|
+
? JSON.parse(JSON.stringify(newDefinition))
|
|
130
|
+
: { steps: [], transitions: [] }
|
|
131
|
+
isDirty.value = false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const markSaved = () => { isDirty.value = false }
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
// State
|
|
138
|
+
definition,
|
|
139
|
+
isDirty,
|
|
140
|
+
saving,
|
|
141
|
+
isValid,
|
|
142
|
+
// Step CRUD
|
|
143
|
+
addStep,
|
|
144
|
+
updateStep,
|
|
145
|
+
deleteStep,
|
|
146
|
+
reorderSteps,
|
|
147
|
+
// Transition CRUD
|
|
148
|
+
addTransition,
|
|
149
|
+
updateTransition,
|
|
150
|
+
deleteTransition,
|
|
151
|
+
// Restriction helpers
|
|
152
|
+
addRestriction,
|
|
153
|
+
removeRestriction,
|
|
154
|
+
// Computed
|
|
155
|
+
stepByKey,
|
|
156
|
+
transitionsFrom,
|
|
157
|
+
transitionsTo,
|
|
158
|
+
// Utils
|
|
159
|
+
reset,
|
|
160
|
+
markSaved,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWorkflow — punto de entrada único del sistema de workflows.
|
|
3
|
+
*
|
|
4
|
+
* Estructura:
|
|
5
|
+
* useWorkflowForm → edición de definiciones (steps, transitions, restrictions)
|
|
6
|
+
* useWorkflowView → estado operacional (items, columnas, onMove, viewMode)
|
|
7
|
+
*
|
|
8
|
+
* Uso independiente:
|
|
9
|
+
* import { useWorkflowForm } from '~/composables/useWorkflow'
|
|
10
|
+
* import { useWorkflowView } from '~/composables/useWorkflow'
|
|
11
|
+
*
|
|
12
|
+
* Con auto-import de Nuxt también funcionan directamente:
|
|
13
|
+
* const form = useWorkflowForm(initialDefinition)
|
|
14
|
+
* const view = useWorkflowView(form.definition, { endpoint: 'tasks' })
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { useWorkflowForm } from './form.js'
|
|
18
|
+
export { useWorkflowView } from './view.js'
|