@innertia-solutions/innertia-nuxt 0.1.11 → 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/CHANGELOG.md +38 -0
- package/app.config.ts +10 -10
- package/components/Attention.vue +269 -0
- package/components/Auth/LoginForm.vue +1 -1
- 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/MobileBlocker.vue +5 -5
- package/components/MobileLoginPicker.vue +13 -13
- 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/Organization/Picker.vue +3 -3
- 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/useAuth.js +1 -1
- package/composables/useClipboard.js +79 -0
- package/composables/useConfetti.js +194 -0
- package/composables/useContext.js +53 -5
- package/composables/useFile.js +189 -0
- package/composables/useMobileGuard.ts +21 -21
- package/composables/useModal.js +71 -0
- package/composables/useOrganization.ts +3 -3
- 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/docs/superpowers/plans/2026-05-27-nuxt-apps-to-contexts-rename.md +630 -0
- package/docs/superpowers/specs/2026-05-27-nuxt-apps-to-contexts-rename-design.md +113 -0
- package/middleware/{03.apps.global.ts → 03.contexts.global.ts} +25 -25
- package/nuxt.config.ts +1 -1
- package/package.json +13 -8
- package/stores/modal.js +48 -0
- package/stores/toast.js +156 -117
- package/composables/useApp.ts +0 -46
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
IconCloudUpload, IconX, IconCheck, IconAlertCircle, IconLoader2,
|
|
4
|
+
IconPlayerPause, IconRotateClockwise, IconTrash,
|
|
5
|
+
} from '@tabler/icons-vue'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* <FilesUpload> — Zona de upload con drag & drop + cards per file.
|
|
9
|
+
*
|
|
10
|
+
* Estados por archivo: pending → uploading → processing → success | error
|
|
11
|
+
*
|
|
12
|
+
* - uploading: el XHR está enviando el binario (0-100% real)
|
|
13
|
+
* - processing: el cliente terminó (100%) pero el server aún no respondió.
|
|
14
|
+
* Se muestra como indeterminate hasta resolver.
|
|
15
|
+
*
|
|
16
|
+
* El consumer recibe `@uploaded(file)` por cada archivo exitoso y
|
|
17
|
+
* `@error({ file, error })` por cada fallo. `@complete(results)` al terminar todos.
|
|
18
|
+
*/
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
endpoint: { type: String, required: true },
|
|
21
|
+
field: { type: String, default: 'file' },
|
|
22
|
+
multiple: { type: Boolean, default: false },
|
|
23
|
+
accept: { type: String, default: '' },
|
|
24
|
+
maxSizeMb: { type: Number, default: 0 },
|
|
25
|
+
maxFiles: { type: Number, default: 0 },
|
|
26
|
+
autoUpload: { type: Boolean, default: true },
|
|
27
|
+
extraData: { type: Object, default: () => ({}) },
|
|
28
|
+
clearOnSuccess: { type: Boolean, default: false },
|
|
29
|
+
hint: { type: String, default: 'Arrastrá archivos o hacé click para seleccionar' },
|
|
30
|
+
hintSecondary: { type: String, default: '' },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits(['uploaded', 'error', 'complete', 'change'])
|
|
34
|
+
|
|
35
|
+
const { upload, formatSize, iconFor } = useFile()
|
|
36
|
+
|
|
37
|
+
const inputRef = ref(null)
|
|
38
|
+
const dragOver = ref(false)
|
|
39
|
+
const queue = ref([])
|
|
40
|
+
|
|
41
|
+
let nextId = 1
|
|
42
|
+
const newItem = (file) => ({
|
|
43
|
+
id: nextId++,
|
|
44
|
+
file,
|
|
45
|
+
name: file.name,
|
|
46
|
+
size: file.size,
|
|
47
|
+
type: file.type || 'application/octet-stream',
|
|
48
|
+
status: 'pending', // pending | uploading | processing | success | error
|
|
49
|
+
progress: 0,
|
|
50
|
+
loaded: 0,
|
|
51
|
+
speed: 0, // bytes/s
|
|
52
|
+
eta: 0, // segundos restantes
|
|
53
|
+
startedAt: 0,
|
|
54
|
+
response: null,
|
|
55
|
+
error: null,
|
|
56
|
+
controller: null,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
watch(queue, (q) => emit('change', q), { deep: true })
|
|
60
|
+
|
|
61
|
+
const maxBytes = computed(() => props.maxSizeMb > 0 ? props.maxSizeMb * 1024 * 1024 : Infinity)
|
|
62
|
+
const validate = (file) => file.size > maxBytes.value ? `Excede ${props.maxSizeMb}MB` : null
|
|
63
|
+
|
|
64
|
+
const openPicker = () => inputRef.value?.click()
|
|
65
|
+
|
|
66
|
+
const addFiles = (fileList) => {
|
|
67
|
+
const incoming = Array.from(fileList)
|
|
68
|
+
if (!incoming.length) return
|
|
69
|
+
|
|
70
|
+
let toAdd = incoming
|
|
71
|
+
if (props.multiple) {
|
|
72
|
+
if (props.maxFiles > 0) {
|
|
73
|
+
const slots = props.maxFiles - queue.value.length
|
|
74
|
+
if (slots <= 0) return
|
|
75
|
+
toAdd = incoming.slice(0, slots)
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
queue.value = []
|
|
79
|
+
toAdd = [incoming[0]]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const f of toAdd) {
|
|
83
|
+
const item = newItem(f)
|
|
84
|
+
const err = validate(f)
|
|
85
|
+
if (err) { item.status = 'error'; item.error = err }
|
|
86
|
+
queue.value.push(item)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (props.autoUpload) nextTick(uploadAll)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const uploadOne = async (item) => {
|
|
93
|
+
if (item.status !== 'pending') return
|
|
94
|
+
item.status = 'uploading'
|
|
95
|
+
item.startedAt = Date.now()
|
|
96
|
+
item.controller = new AbortController()
|
|
97
|
+
try {
|
|
98
|
+
const res = await upload(props.endpoint, item.file, {
|
|
99
|
+
field: props.field,
|
|
100
|
+
extraData: props.extraData,
|
|
101
|
+
signal: item.controller.signal,
|
|
102
|
+
onProgress: (p, loaded) => {
|
|
103
|
+
item.progress = p
|
|
104
|
+
item.loaded = loaded
|
|
105
|
+
const elapsed = (Date.now() - item.startedAt) / 1000
|
|
106
|
+
if (elapsed > 0) {
|
|
107
|
+
item.speed = loaded / elapsed
|
|
108
|
+
if (item.speed > 0) item.eta = Math.max(0, (item.size - loaded) / item.speed)
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
onUploaded: () => {
|
|
112
|
+
// Upload del cliente terminó, esperando server.
|
|
113
|
+
if (item.status === 'uploading') {
|
|
114
|
+
item.status = 'processing'
|
|
115
|
+
item.progress = 100
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
item.status = 'success'
|
|
120
|
+
item.progress = 100
|
|
121
|
+
item.response = res
|
|
122
|
+
emit('uploaded', res, item)
|
|
123
|
+
if (props.clearOnSuccess) setTimeout(() => removeItem(item.id), 800)
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e?.aborted) return
|
|
126
|
+
item.status = 'error'
|
|
127
|
+
item.error = e?.data?.message ?? e.message ?? 'Error en la carga'
|
|
128
|
+
emit('error', { item, error: e })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const uploadAll = async () => {
|
|
133
|
+
const pending = queue.value.filter(i => i.status === 'pending')
|
|
134
|
+
if (!pending.length) return
|
|
135
|
+
await Promise.allSettled(pending.map(uploadOne))
|
|
136
|
+
emit('complete', {
|
|
137
|
+
success: queue.value.filter(i => i.status === 'success'),
|
|
138
|
+
failed: queue.value.filter(i => i.status === 'error'),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const retryItem = (item) => {
|
|
143
|
+
item.status = 'pending'
|
|
144
|
+
item.progress = 0
|
|
145
|
+
item.loaded = 0
|
|
146
|
+
item.error = null
|
|
147
|
+
uploadOne(item)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const removeItem = (id) => {
|
|
151
|
+
const idx = queue.value.findIndex(i => i.id === id)
|
|
152
|
+
if (idx === -1) return
|
|
153
|
+
const item = queue.value[idx]
|
|
154
|
+
if (item.status === 'uploading' || item.status === 'processing') item.controller?.abort()
|
|
155
|
+
queue.value.splice(idx, 1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clearAll = () => {
|
|
159
|
+
for (const it of queue.value) {
|
|
160
|
+
if (it.status === 'uploading' || it.status === 'processing') it.controller?.abort()
|
|
161
|
+
}
|
|
162
|
+
queue.value = []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cancelItem = (item) => {
|
|
166
|
+
if (item.status === 'uploading' || item.status === 'processing') {
|
|
167
|
+
item.controller?.abort()
|
|
168
|
+
item.status = 'error'
|
|
169
|
+
item.error = 'Cancelado'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Drag & drop ────────────────────────────────────────────────────────────
|
|
174
|
+
const onDragOver = (e) => { e.preventDefault(); dragOver.value = true }
|
|
175
|
+
const onDragLeave = (e) => { e.preventDefault(); dragOver.value = false }
|
|
176
|
+
const onDrop = (e) => {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
dragOver.value = false
|
|
179
|
+
if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const onInputChange = (e) => {
|
|
183
|
+
if (e.target?.files?.length) addFiles(e.target.files)
|
|
184
|
+
e.target.value = ''
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Helpers de UI ──────────────────────────────────────────────────────────
|
|
188
|
+
const formatSpeed = (bps) => {
|
|
189
|
+
if (!bps || bps < 1) return ''
|
|
190
|
+
if (bps < 1024) return `${bps.toFixed(0)} B/s`
|
|
191
|
+
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`
|
|
192
|
+
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
|
193
|
+
}
|
|
194
|
+
const formatEta = (s) => {
|
|
195
|
+
if (!s || !isFinite(s) || s < 1) return ''
|
|
196
|
+
if (s < 60) return `${Math.ceil(s)}s`
|
|
197
|
+
return `${Math.ceil(s / 60)}m`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const summary = computed(() => {
|
|
201
|
+
const total = queue.value.length
|
|
202
|
+
const ok = queue.value.filter(i => i.status === 'success').length
|
|
203
|
+
const err = queue.value.filter(i => i.status === 'error').length
|
|
204
|
+
const active = queue.value.filter(i => i.status === 'uploading' || i.status === 'processing').length
|
|
205
|
+
return { total, ok, err, active }
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
defineExpose({ open: openPicker, uploadAll, clearAll, removeItem, queue })
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<template>
|
|
212
|
+
<div class="space-y-3">
|
|
213
|
+
<!-- ── Dropzone ────────────────────────────────────────────────────── -->
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
:class="[
|
|
217
|
+
'w-full flex flex-col items-center justify-center gap-2 px-4 py-8 rounded-card border-2 border-dashed transition-colors text-center',
|
|
218
|
+
dragOver
|
|
219
|
+
? 'border-primary bg-primary/5 text-primary'
|
|
220
|
+
: 'border-card-line hover:border-primary/40 hover:bg-muted-hover/30 text-muted-foreground',
|
|
221
|
+
]"
|
|
222
|
+
@click="openPicker"
|
|
223
|
+
@dragover.prevent="onDragOver"
|
|
224
|
+
@dragleave.prevent="onDragLeave"
|
|
225
|
+
@drop.prevent="onDrop"
|
|
226
|
+
>
|
|
227
|
+
<IconCloudUpload :size="32" :stroke="1.5" />
|
|
228
|
+
<div>
|
|
229
|
+
<p class="text-sm font-medium text-foreground">{{ hint }}</p>
|
|
230
|
+
<p v-if="hintSecondary" class="text-xs text-muted-foreground mt-1">{{ hintSecondary }}</p>
|
|
231
|
+
<p v-else-if="maxSizeMb || accept" class="text-xs text-muted-foreground mt-1">
|
|
232
|
+
<span v-if="accept">{{ accept }}</span>
|
|
233
|
+
<span v-if="accept && maxSizeMb"> · </span>
|
|
234
|
+
<span v-if="maxSizeMb">máx. {{ maxSizeMb }}MB por archivo</span>
|
|
235
|
+
</p>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<input
|
|
239
|
+
ref="inputRef"
|
|
240
|
+
type="file"
|
|
241
|
+
class="hidden"
|
|
242
|
+
:multiple="multiple"
|
|
243
|
+
:accept="accept || undefined"
|
|
244
|
+
@change="onInputChange"
|
|
245
|
+
/>
|
|
246
|
+
</button>
|
|
247
|
+
|
|
248
|
+
<!-- ── Summary chip ─────────────────────────────────────────────────── -->
|
|
249
|
+
<div v-if="queue.length" class="flex items-center justify-between text-xs">
|
|
250
|
+
<div class="flex items-center gap-3 text-muted-foreground">
|
|
251
|
+
<span>{{ summary.total }} archivo{{ summary.total === 1 ? '' : 's' }}</span>
|
|
252
|
+
<span v-if="summary.active" class="text-primary inline-flex items-center gap-1">
|
|
253
|
+
<IconLoader2 :size="12" class="animate-spin" /> {{ summary.active }} en progreso
|
|
254
|
+
</span>
|
|
255
|
+
<span v-if="summary.ok" class="text-emerald-600 dark:text-emerald-400">{{ summary.ok }} ok</span>
|
|
256
|
+
<span v-if="summary.err" class="text-red-500">{{ summary.err }} con error</span>
|
|
257
|
+
</div>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
class="inline-flex items-center gap-1 px-2 py-1 rounded-control text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
261
|
+
@click="clearAll"
|
|
262
|
+
>
|
|
263
|
+
<IconTrash :size="12" /> Limpiar
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- ── Cards per file (estilo Preline file-uploading-progress-form) ── -->
|
|
268
|
+
<TransitionGroup
|
|
269
|
+
v-if="queue.length"
|
|
270
|
+
tag="ul"
|
|
271
|
+
class="space-y-2"
|
|
272
|
+
enter-active-class="transition ease-out duration-200"
|
|
273
|
+
enter-from-class="opacity-0 -translate-y-1"
|
|
274
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
275
|
+
leave-active-class="transition ease-in duration-150"
|
|
276
|
+
leave-from-class="opacity-100"
|
|
277
|
+
leave-to-class="opacity-0"
|
|
278
|
+
>
|
|
279
|
+
<li
|
|
280
|
+
v-for="item in queue"
|
|
281
|
+
:key="item.id"
|
|
282
|
+
:class="[
|
|
283
|
+
'relative p-3 rounded-card border bg-card transition-colors',
|
|
284
|
+
item.status === 'error' ? 'border-red-500/30 bg-red-500/[0.02]'
|
|
285
|
+
: item.status === 'success' ? 'border-emerald-500/30 bg-emerald-500/[0.02]'
|
|
286
|
+
: 'border-card-line',
|
|
287
|
+
]"
|
|
288
|
+
>
|
|
289
|
+
<div class="flex items-start gap-3">
|
|
290
|
+
<!-- File type icon -->
|
|
291
|
+
<div
|
|
292
|
+
:class="[
|
|
293
|
+
'shrink-0 size-10 rounded-control flex items-center justify-center',
|
|
294
|
+
item.status === 'error' ? 'bg-red-500/10 text-red-600 dark:text-red-400'
|
|
295
|
+
: item.status === 'success' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
|
296
|
+
: 'bg-muted text-muted-foreground',
|
|
297
|
+
]"
|
|
298
|
+
>
|
|
299
|
+
<component
|
|
300
|
+
:is="item.status === 'success' ? IconCheck
|
|
301
|
+
: item.status === 'error' ? IconAlertCircle
|
|
302
|
+
: iconFor(item.type)"
|
|
303
|
+
:size="20"
|
|
304
|
+
:stroke="1.75"
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<!-- Body -->
|
|
309
|
+
<div class="flex-1 min-w-0">
|
|
310
|
+
<div class="flex items-start justify-between gap-2">
|
|
311
|
+
<div class="min-w-0 flex-1">
|
|
312
|
+
<p class="text-sm font-medium text-foreground truncate">{{ item.name }}</p>
|
|
313
|
+
<p class="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
|
314
|
+
<template v-if="item.status === 'uploading'">
|
|
315
|
+
<span>{{ formatSize(item.loaded) }} / {{ formatSize(item.size) }}</span>
|
|
316
|
+
<span v-if="item.speed" class="mx-1.5">·</span>
|
|
317
|
+
<span v-if="item.speed">{{ formatSpeed(item.speed) }}</span>
|
|
318
|
+
<span v-if="item.eta" class="mx-1.5">·</span>
|
|
319
|
+
<span v-if="item.eta">{{ formatEta(item.eta) }} restantes</span>
|
|
320
|
+
</template>
|
|
321
|
+
<template v-else-if="item.status === 'processing'">
|
|
322
|
+
<span class="text-primary inline-flex items-center gap-1">
|
|
323
|
+
<IconLoader2 :size="11" class="animate-spin" /> Procesando en el servidor…
|
|
324
|
+
</span>
|
|
325
|
+
</template>
|
|
326
|
+
<template v-else-if="item.status === 'success'">
|
|
327
|
+
<span class="text-emerald-600 dark:text-emerald-400">Subido</span>
|
|
328
|
+
<span class="mx-1.5">·</span>
|
|
329
|
+
<span>{{ formatSize(item.size) }}</span>
|
|
330
|
+
</template>
|
|
331
|
+
<template v-else-if="item.status === 'error'">
|
|
332
|
+
<span class="text-red-500">{{ item.error }}</span>
|
|
333
|
+
</template>
|
|
334
|
+
<template v-else>
|
|
335
|
+
<span>{{ formatSize(item.size) }}</span>
|
|
336
|
+
</template>
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<!-- Actions -->
|
|
341
|
+
<div class="flex items-center gap-0.5 shrink-0">
|
|
342
|
+
<button
|
|
343
|
+
v-if="item.status === 'error'"
|
|
344
|
+
type="button"
|
|
345
|
+
title="Reintentar"
|
|
346
|
+
class="inline-flex items-center justify-center size-7 rounded-control text-primary hover:bg-primary/10 transition-colors"
|
|
347
|
+
@click="retryItem(item)"
|
|
348
|
+
>
|
|
349
|
+
<IconRotateClockwise class="size-3.5" />
|
|
350
|
+
</button>
|
|
351
|
+
<button
|
|
352
|
+
v-if="item.status === 'uploading' || item.status === 'processing'"
|
|
353
|
+
type="button"
|
|
354
|
+
title="Cancelar"
|
|
355
|
+
class="inline-flex items-center justify-center size-7 rounded-control text-muted-foreground hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
|
|
356
|
+
@click="cancelItem(item)"
|
|
357
|
+
>
|
|
358
|
+
<IconPlayerPause class="size-3.5" />
|
|
359
|
+
</button>
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
title="Quitar"
|
|
363
|
+
class="inline-flex items-center justify-center size-7 rounded-control text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
364
|
+
@click="removeItem(item.id)"
|
|
365
|
+
>
|
|
366
|
+
<IconX class="size-3.5" />
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Progress bar (uploading + processing) -->
|
|
372
|
+
<div
|
|
373
|
+
v-if="item.status === 'uploading' || item.status === 'processing'"
|
|
374
|
+
class="mt-2 flex items-center gap-2"
|
|
375
|
+
>
|
|
376
|
+
<div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden relative">
|
|
377
|
+
<!-- Determinate progress -->
|
|
378
|
+
<div
|
|
379
|
+
v-if="item.status === 'uploading'"
|
|
380
|
+
class="h-full bg-primary transition-all duration-150"
|
|
381
|
+
:style="{ width: item.progress + '%' }"
|
|
382
|
+
/>
|
|
383
|
+
<!-- Indeterminate shimmer for processing -->
|
|
384
|
+
<div
|
|
385
|
+
v-else
|
|
386
|
+
class="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent indeterminate-shimmer"
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
<span class="text-[11px] font-mono tabular-nums text-muted-foreground w-10 text-right">
|
|
390
|
+
{{ item.status === 'uploading' ? `${item.progress}%` : '…' }}
|
|
391
|
+
</span>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</li>
|
|
396
|
+
</TransitionGroup>
|
|
397
|
+
</div>
|
|
398
|
+
</template>
|
|
399
|
+
|
|
400
|
+
<style scoped>
|
|
401
|
+
@keyframes shimmer {
|
|
402
|
+
0% { transform: translateX(-100%); }
|
|
403
|
+
100% { transform: translateX(100%); }
|
|
404
|
+
}
|
|
405
|
+
.indeterminate-shimmer {
|
|
406
|
+
animation: shimmer 1.2s ease-in-out infinite;
|
|
407
|
+
}
|
|
408
|
+
</style>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* <FilesUploadModal> — `<FilesUpload>` wrappeado en `<Modal>`.
|
|
4
|
+
*
|
|
5
|
+
* Uso:
|
|
6
|
+
* <FilesUploadModal
|
|
7
|
+
* v-model:open="showUpload"
|
|
8
|
+
* endpoint="backoffice/invoices/123/files"
|
|
9
|
+
* title="Adjuntar comprobante"
|
|
10
|
+
* :max-size-mb="10"
|
|
11
|
+
* accept=".pdf"
|
|
12
|
+
* @uploaded="(file) => attach(file)"
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
const props = defineProps({
|
|
16
|
+
/** Endpoint multipart (idem FilesUpload). */
|
|
17
|
+
endpoint: { type: String, required: true },
|
|
18
|
+
|
|
19
|
+
/** Título del modal. */
|
|
20
|
+
title: { type: String, default: 'Subir archivo' },
|
|
21
|
+
|
|
22
|
+
/** Tamaño del modal. */
|
|
23
|
+
size: { type: String, default: 'lg' },
|
|
24
|
+
|
|
25
|
+
// Pass-through a FilesUpload
|
|
26
|
+
field: { type: String, default: 'file' },
|
|
27
|
+
multiple: { type: Boolean, default: false },
|
|
28
|
+
accept: { type: String, default: '' },
|
|
29
|
+
maxSizeMb: { type: Number, default: 0 },
|
|
30
|
+
maxFiles: { type: Number, default: 0 },
|
|
31
|
+
autoUpload: { type: Boolean, default: true },
|
|
32
|
+
extraData: { type: Object, default: () => ({}) },
|
|
33
|
+
hint: { type: String, default: 'Arrastrá archivos o hacé click para seleccionar' },
|
|
34
|
+
hintSecondary: { type: String, default: '' },
|
|
35
|
+
|
|
36
|
+
/** Cerrar el modal automáticamente cuando todos los uploads terminan exitosos. */
|
|
37
|
+
closeOnComplete: { type: Boolean, default: true },
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const open = defineModel('open', { default: false })
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits(['uploaded', 'error', 'complete'])
|
|
43
|
+
|
|
44
|
+
const uploadRef = ref(null)
|
|
45
|
+
|
|
46
|
+
const handleComplete = (results) => {
|
|
47
|
+
emit('complete', results)
|
|
48
|
+
if (props.closeOnComplete && results.failed.length === 0 && results.success.length > 0) {
|
|
49
|
+
// Pequeño delay para que el usuario vea los checkmarks verdes antes de cerrar
|
|
50
|
+
setTimeout(() => { open.value = false }, 600)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
watch(open, (v) => {
|
|
55
|
+
if (!v) uploadRef.value?.clearAll()
|
|
56
|
+
})
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<Modal
|
|
61
|
+
v-model="open"
|
|
62
|
+
:title="title"
|
|
63
|
+
:size="size"
|
|
64
|
+
:backdrop-dismiss="false"
|
|
65
|
+
>
|
|
66
|
+
<FilesUpload
|
|
67
|
+
ref="uploadRef"
|
|
68
|
+
:endpoint="endpoint"
|
|
69
|
+
:field="field"
|
|
70
|
+
:multiple="multiple"
|
|
71
|
+
:accept="accept"
|
|
72
|
+
:max-size-mb="maxSizeMb"
|
|
73
|
+
:max-files="maxFiles"
|
|
74
|
+
:auto-upload="autoUpload"
|
|
75
|
+
:extra-data="extraData"
|
|
76
|
+
:hint="hint"
|
|
77
|
+
:hint-secondary="hintSecondary"
|
|
78
|
+
@uploaded="(file, item) => emit('uploaded', file, item)"
|
|
79
|
+
@error="(err) => emit('error', err)"
|
|
80
|
+
@complete="handleComplete"
|
|
81
|
+
/>
|
|
82
|
+
</Modal>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* <FormsInputPin> — Input para códigos PIN / OTP.
|
|
4
|
+
*
|
|
5
|
+
* Características:
|
|
6
|
+
* - Longitud configurable (default 4, soporta 4-8 dígitos)
|
|
7
|
+
* - Acepta solo dígitos (o letras si type="text")
|
|
8
|
+
* - Auto-focus al siguiente al escribir, retroceso al anterior con Backspace
|
|
9
|
+
* - Paste inteligente — pegás "123456" y se distribuye
|
|
10
|
+
* - Estados: default, error, success (vía prop o slot)
|
|
11
|
+
* - Mask opcional para passwords / códigos sensibles
|
|
12
|
+
* - Emite @complete cuando todos los slots están llenos
|
|
13
|
+
*
|
|
14
|
+
* ```vue
|
|
15
|
+
* <FormsInputPin v-model="otp" :length="6" @complete="verify" />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
modelValue: { type: String, default: '' },
|
|
20
|
+
length: { type: Number, default: 6 },
|
|
21
|
+
/** 'number' (default) acepta solo dígitos, 'text' acepta letras también. */
|
|
22
|
+
type: { type: String, default: 'number', validator: v => ['number','text'].includes(v) },
|
|
23
|
+
/** Oculta los caracteres (estilo password). */
|
|
24
|
+
masked: { type: Boolean, default: false },
|
|
25
|
+
disabled: { type: Boolean, default: false },
|
|
26
|
+
/** Estado de validación visual. */
|
|
27
|
+
state: { type: String, default: 'default', validator: v => ['default','error','success'].includes(v) },
|
|
28
|
+
size: { type: String, default: 'md', validator: v => ['sm','md','lg'].includes(v) },
|
|
29
|
+
/** Separador visual cada N slots. Ej. 3 para "123-456". */
|
|
30
|
+
separator: { type: Number, default: 0 },
|
|
31
|
+
autofocus: { type: Boolean, default: false },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits(['update:modelValue', 'complete', 'change'])
|
|
35
|
+
|
|
36
|
+
const inputs = ref([])
|
|
37
|
+
const slots = ref(Array.from({ length: props.length }, (_, i) =>
|
|
38
|
+
(props.modelValue || '').charAt(i) || ''
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
watch(() => props.modelValue, (val) => {
|
|
42
|
+
const arr = Array.from({ length: props.length }, (_, i) => (val || '').charAt(i) || '')
|
|
43
|
+
if (arr.join('') !== slots.value.join('')) slots.value = arr
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
watch(() => props.length, (n) => {
|
|
47
|
+
const cur = slots.value.join('').slice(0, n)
|
|
48
|
+
slots.value = Array.from({ length: n }, (_, i) => cur.charAt(i) || '')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const sanitize = (ch) => {
|
|
52
|
+
if (!ch) return ''
|
|
53
|
+
if (props.type === 'number') return /[0-9]/.test(ch) ? ch : ''
|
|
54
|
+
return /[a-zA-Z0-9]/.test(ch) ? ch : ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sync = () => {
|
|
58
|
+
const v = slots.value.join('')
|
|
59
|
+
emit('update:modelValue', v)
|
|
60
|
+
emit('change', v)
|
|
61
|
+
if (v.length === props.length && !v.includes('')) emit('complete', v)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const focusSlot = async (i) => {
|
|
65
|
+
await nextTick()
|
|
66
|
+
inputs.value[i]?.focus()
|
|
67
|
+
inputs.value[i]?.select?.()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const onInput = (e, i) => {
|
|
71
|
+
const raw = e.target.value
|
|
72
|
+
const last = raw.slice(-1)
|
|
73
|
+
const ok = sanitize(last)
|
|
74
|
+
slots.value[i] = ok
|
|
75
|
+
e.target.value = ok
|
|
76
|
+
sync()
|
|
77
|
+
if (ok && i < props.length - 1) focusSlot(i + 1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const onKeydown = (e, i) => {
|
|
81
|
+
if (e.key === 'Backspace') {
|
|
82
|
+
if (slots.value[i]) {
|
|
83
|
+
slots.value[i] = ''
|
|
84
|
+
sync()
|
|
85
|
+
} else if (i > 0) {
|
|
86
|
+
slots.value[i - 1] = ''
|
|
87
|
+
sync()
|
|
88
|
+
focusSlot(i - 1)
|
|
89
|
+
}
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
} else if (e.key === 'ArrowLeft' && i > 0) {
|
|
92
|
+
focusSlot(i - 1)
|
|
93
|
+
} else if (e.key === 'ArrowRight' && i < props.length - 1) {
|
|
94
|
+
focusSlot(i + 1)
|
|
95
|
+
} else if (e.key === 'Enter') {
|
|
96
|
+
const v = slots.value.join('')
|
|
97
|
+
if (v.length === props.length && !v.includes('')) emit('complete', v)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onPaste = (e, i) => {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
const text = (e.clipboardData?.getData('text') ?? '').trim()
|
|
104
|
+
if (!text) return
|
|
105
|
+
let j = i
|
|
106
|
+
for (const ch of text) {
|
|
107
|
+
const ok = sanitize(ch)
|
|
108
|
+
if (!ok) continue
|
|
109
|
+
if (j >= props.length) break
|
|
110
|
+
slots.value[j] = ok
|
|
111
|
+
j++
|
|
112
|
+
}
|
|
113
|
+
sync()
|
|
114
|
+
focusSlot(Math.min(j, props.length - 1))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const onFocus = (e) => e.target.select?.()
|
|
118
|
+
|
|
119
|
+
onMounted(() => {
|
|
120
|
+
if (props.autofocus) focusSlot(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const slotClass = computed(() => {
|
|
124
|
+
const sizeMap = {
|
|
125
|
+
sm: 'size-9 text-base',
|
|
126
|
+
md: 'size-11 text-lg',
|
|
127
|
+
lg: 'size-14 text-2xl',
|
|
128
|
+
}[props.size]
|
|
129
|
+
const stateMap = {
|
|
130
|
+
default: 'border-card-line focus:border-primary focus:ring-2 focus:ring-primary/20',
|
|
131
|
+
error: 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 text-red-600',
|
|
132
|
+
success: 'border-emerald-500 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 text-emerald-600',
|
|
133
|
+
}[props.state]
|
|
134
|
+
return `${sizeMap} ${stateMap} text-center font-mono font-semibold tabular-nums bg-card text-foreground rounded-control border outline-none transition-colors`
|
|
135
|
+
})
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<template>
|
|
139
|
+
<div class="inline-flex items-center gap-1.5">
|
|
140
|
+
<template v-for="(_, i) in slots" :key="i">
|
|
141
|
+
<input
|
|
142
|
+
:ref="el => inputs[i] = el"
|
|
143
|
+
:type="masked ? 'password' : (type === 'number' ? 'tel' : 'text')"
|
|
144
|
+
:inputmode="type === 'number' ? 'numeric' : 'text'"
|
|
145
|
+
:pattern="type === 'number' ? '[0-9]*' : null"
|
|
146
|
+
:value="slots[i]"
|
|
147
|
+
:disabled="disabled"
|
|
148
|
+
:maxlength="1"
|
|
149
|
+
autocomplete="one-time-code"
|
|
150
|
+
:class="slotClass"
|
|
151
|
+
@input="e => onInput(e, i)"
|
|
152
|
+
@keydown="e => onKeydown(e, i)"
|
|
153
|
+
@paste="e => onPaste(e, i)"
|
|
154
|
+
@focus="onFocus"
|
|
155
|
+
/>
|
|
156
|
+
<span
|
|
157
|
+
v-if="separator > 0 && (i + 1) % separator === 0 && i < length - 1"
|
|
158
|
+
class="text-muted-foreground text-xl select-none"
|
|
159
|
+
>–</span>
|
|
160
|
+
</template>
|
|
161
|
+
</div>
|
|
162
|
+
</template>
|