@edc-motor/admin-kit 0.2.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/LICENSE +674 -0
- package/README.md +37 -0
- package/package.json +45 -0
- package/scss/_admin-kit.scss +10 -0
- package/scss/components/_admin-layout.scss +310 -0
- package/scss/components/_empty-state.scss +32 -0
- package/scss/components/_entity-card.scss +86 -0
- package/scss/components/_filter-bar.scss +53 -0
- package/scss/components/_grid.scss +31 -0
- package/scss/components/_manager-card.scss +329 -0
- package/scss/components/_page-blocks.scss +179 -0
- package/scss/components/_pdf-manager.scss +46 -0
- package/scss/components/_preview-manager.scss +5 -0
- package/scss/components/_right-sidebar.scss +152 -0
- package/src/components/ManagerCard.vue +57 -0
- package/src/composables/useRightSidebar.ts +90 -0
- package/src/content/EntityRefSelect.vue +58 -0
- package/src/content/PageBlocks.vue +449 -0
- package/src/content/SchemaFields.vue +308 -0
- package/src/crud/BaseGrid.vue +38 -0
- package/src/crud/EmptyState.vue +13 -0
- package/src/crud/EntityCard.vue +51 -0
- package/src/crud/FilterBar.vue +28 -0
- package/src/crud/useResource.ts +83 -0
- package/src/index.ts +20 -0
- package/src/layout/AdminLayout.vue +181 -0
- package/src/layout/RightSidebar.vue +126 -0
- package/src/pdf/PdfManager.vue +407 -0
- package/src/previews/PreviewManager.vue +398 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
import type { AxiosInstance } from 'axios'
|
|
4
|
+
import { Download, FilePlus, RefreshCw, Trash2 } from '@lucide/vue'
|
|
5
|
+
import { BaseButton, IconButton, SearchSelect, useConfirm, useToast } from '@edc-motor/ui'
|
|
6
|
+
import ManagerCard from '../components/ManagerCard.vue'
|
|
7
|
+
import { useRightSidebar } from '../composables/useRightSidebar'
|
|
8
|
+
|
|
9
|
+
// Gestor de PDF del juego (doc 02), mobile-first. Una tarjeta FIJA por export
|
|
10
|
+
// del catálogo (GET /admin/pdfs/exports) con las MISMAS estadísticas que las
|
|
11
|
+
// previews: total de piezas y listas por idioma. Las acciones "de todas" son
|
|
12
|
+
// las de las previews — generar faltantes (tarjeta y panel), regenerar todo
|
|
13
|
+
// y borrar todo (panel) — y el panel añade, en los exports por entidad, un
|
|
14
|
+
// COMBOBOX (select con buscador) de la entidad dueña con sus PDF por idioma.
|
|
15
|
+
// Toda la gestión de PDF vive aquí (no en los detalles de las entidades).
|
|
16
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
17
|
+
|
|
18
|
+
export interface PdfManagerLabels {
|
|
19
|
+
refresh: string
|
|
20
|
+
generate: string
|
|
21
|
+
generateMissing: string
|
|
22
|
+
regenerateAll: string
|
|
23
|
+
deleteAll: string
|
|
24
|
+
download: string
|
|
25
|
+
regenerate: string
|
|
26
|
+
delete: string
|
|
27
|
+
confirmDelete: string
|
|
28
|
+
confirmRegenerateAll: string
|
|
29
|
+
confirmDeleteAll: string
|
|
30
|
+
confirm: string
|
|
31
|
+
cancel: string
|
|
32
|
+
empty: string
|
|
33
|
+
error: string
|
|
34
|
+
statusPending: string
|
|
35
|
+
statusReady: string
|
|
36
|
+
statusFailed: string
|
|
37
|
+
detailTitle: string
|
|
38
|
+
panelEmpty: string
|
|
39
|
+
selectSource: string
|
|
40
|
+
searchPlaceholder: string
|
|
41
|
+
noResults: string
|
|
42
|
+
generatedAt: string
|
|
43
|
+
total: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultLabels: PdfManagerLabels = {
|
|
47
|
+
refresh: 'Actualizar',
|
|
48
|
+
generate: 'Generar',
|
|
49
|
+
generateMissing: 'Generar faltantes',
|
|
50
|
+
regenerateAll: 'Regenerar todo',
|
|
51
|
+
deleteAll: 'Borrar todo',
|
|
52
|
+
download: 'Descargar',
|
|
53
|
+
regenerate: 'Regenerar',
|
|
54
|
+
delete: 'Borrar',
|
|
55
|
+
confirmDelete: '¿Borrar el PDF de "{name}"?',
|
|
56
|
+
confirmRegenerateAll: '¿Regenerar TODOS los PDF de {type}?',
|
|
57
|
+
confirmDeleteAll: '¿Borrar TODOS los PDF de {type}?',
|
|
58
|
+
confirm: 'Confirmar',
|
|
59
|
+
cancel: 'Cancelar',
|
|
60
|
+
empty: 'Aún no hay PDF generados.',
|
|
61
|
+
error: 'No se ha podido completar la acción.',
|
|
62
|
+
statusPending: 'En cola…',
|
|
63
|
+
statusReady: 'Listo',
|
|
64
|
+
statusFailed: 'Error',
|
|
65
|
+
detailTitle: 'PDF',
|
|
66
|
+
panelEmpty: 'Selecciona una tarjeta para gestionar sus PDF.',
|
|
67
|
+
selectSource: 'Elige un elemento…',
|
|
68
|
+
searchPlaceholder: 'Buscar…',
|
|
69
|
+
noResults: 'Sin resultados.',
|
|
70
|
+
generatedAt: 'Generado',
|
|
71
|
+
total: 'Total',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const props = withDefaults(
|
|
75
|
+
defineProps<{
|
|
76
|
+
api: AxiosInstance
|
|
77
|
+
labels?: Partial<PdfManagerLabels>
|
|
78
|
+
/** Nombre traducido de cada export (type => etiqueta). */
|
|
79
|
+
typeLabels?: Record<string, string>
|
|
80
|
+
}>(),
|
|
81
|
+
{ labels: () => ({}), typeLabels: () => ({}) },
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const L = reactive({ ...defaultLabels, ...props.labels }) as PdfManagerLabels
|
|
85
|
+
|
|
86
|
+
const toast = useToast()
|
|
87
|
+
const { confirm } = useConfirm()
|
|
88
|
+
|
|
89
|
+
// El panel derecho enseña los PDF del export seleccionado.
|
|
90
|
+
const sidebar = useRightSidebar()
|
|
91
|
+
sidebar.useRegister(L.detailTitle)
|
|
92
|
+
|
|
93
|
+
interface ExportInfo {
|
|
94
|
+
type: string
|
|
95
|
+
global: boolean
|
|
96
|
+
layout: string
|
|
97
|
+
sources: { id: number; label: string }[]
|
|
98
|
+
stats: { total: number; locales: Record<string, number> }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface PdfRow {
|
|
102
|
+
id: number
|
|
103
|
+
locale: string
|
|
104
|
+
status: 'pending' | 'ready' | 'failed'
|
|
105
|
+
error: string | null
|
|
106
|
+
url: string | null
|
|
107
|
+
filename: string
|
|
108
|
+
generated_at: string | null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const exports = ref<ExportInfo[]>([])
|
|
112
|
+
const loading = ref(true)
|
|
113
|
+
const busy = ref(false)
|
|
114
|
+
|
|
115
|
+
// Filas por clave "type:sourceId|global".
|
|
116
|
+
const rows = reactive<Record<string, PdfRow[]>>({})
|
|
117
|
+
// Export activo (tarjeta) + entidad dueña elegida en el selector del panel.
|
|
118
|
+
const activeType = ref<string | null>(null)
|
|
119
|
+
const sourceSearch = ref('')
|
|
120
|
+
const selectedSourceId = ref<number | null>(null)
|
|
121
|
+
|
|
122
|
+
function keyOf(type: string, sourceId: number | null): string {
|
|
123
|
+
return `${type}:${sourceId ?? 'global'}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function typeName(exp: ExportInfo): string {
|
|
127
|
+
return props.typeLabels[exp.type] ?? exp.type
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Quedan piezas por generar (algún idioma por debajo del total). */
|
|
131
|
+
function hasMissing(exp: ExportInfo): boolean {
|
|
132
|
+
return Object.values(exp.stats.locales).some((ready) => ready < exp.stats.total)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const activeExport = computed(() => exports.value.find((e) => e.type === activeType.value) ?? null)
|
|
136
|
+
|
|
137
|
+
/** Fuentes filtradas por el buscador del panel (filtro en cliente). */
|
|
138
|
+
const filteredSources = computed(() => {
|
|
139
|
+
if (!activeExport.value) return []
|
|
140
|
+
const q = sourceSearch.value.trim().toLowerCase()
|
|
141
|
+
return q
|
|
142
|
+
? activeExport.value.sources.filter((s) => s.label.toLowerCase().includes(q))
|
|
143
|
+
: activeExport.value.sources
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/** Filas visibles en el panel: las del export global o las de la dueña elegida. */
|
|
147
|
+
const panelRows = computed<PdfRow[] | null>(() => {
|
|
148
|
+
if (!activeExport.value) return null
|
|
149
|
+
if (activeExport.value.global) return rows[keyOf(activeExport.value.type, null)] ?? null
|
|
150
|
+
if (selectedSourceId.value === null) return null
|
|
151
|
+
return rows[keyOf(activeExport.value.type, selectedSourceId.value)] ?? null
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
async function loadRows(type: string, sourceId: number | null) {
|
|
155
|
+
try {
|
|
156
|
+
const { data } = await props.api.get('/admin/pdfs', {
|
|
157
|
+
params: { type, source_id: sourceId ?? undefined },
|
|
158
|
+
})
|
|
159
|
+
rows[keyOf(type, sourceId)] = data.data
|
|
160
|
+
} catch {
|
|
161
|
+
toast.danger(L.error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadCatalog() {
|
|
166
|
+
loading.value = true
|
|
167
|
+
try {
|
|
168
|
+
const { data } = await props.api.get('/admin/pdfs/exports')
|
|
169
|
+
exports.value = data.data
|
|
170
|
+
// Los globales cargan sus filas ya (el panel las pinta directamente).
|
|
171
|
+
await Promise.all(exports.value.filter((e) => e.global).map((e) => loadRows(e.type, null)))
|
|
172
|
+
} catch {
|
|
173
|
+
toast.danger(L.error)
|
|
174
|
+
} finally {
|
|
175
|
+
loading.value = false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function select(exp: ExportInfo) {
|
|
180
|
+
if (activeType.value !== exp.type) {
|
|
181
|
+
activeType.value = exp.type
|
|
182
|
+
sourceSearch.value = ''
|
|
183
|
+
selectedSourceId.value = null
|
|
184
|
+
}
|
|
185
|
+
sidebar.reveal()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function selectSource(id: number) {
|
|
189
|
+
selectedSourceId.value = id
|
|
190
|
+
if (activeExport.value && !rows[keyOf(activeExport.value.type, id)]) {
|
|
191
|
+
loadRows(activeExport.value.type, id)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function refreshAll() {
|
|
196
|
+
await loadCatalog()
|
|
197
|
+
if (activeExport.value && !activeExport.value.global && selectedSourceId.value !== null) {
|
|
198
|
+
await loadRows(activeExport.value.type, selectedSourceId.value)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Envuelve una acción: bloquea botones, toast y refresco del catálogo. */
|
|
203
|
+
async function run(action: () => Promise<{ data: { message?: string } }>) {
|
|
204
|
+
busy.value = true
|
|
205
|
+
try {
|
|
206
|
+
const { data } = await action()
|
|
207
|
+
if (data.message) toast.success(data.message)
|
|
208
|
+
await refreshAll()
|
|
209
|
+
} catch {
|
|
210
|
+
toast.danger(L.error)
|
|
211
|
+
} finally {
|
|
212
|
+
busy.value = false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Acciones "de todas" del export (espejo de las previews) ---
|
|
217
|
+
|
|
218
|
+
function generateMissing(exp: ExportInfo) {
|
|
219
|
+
run(() => props.api.post('/admin/pdfs/generate-missing', { type: exp.type }))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function regenerateAll(exp: ExportInfo) {
|
|
223
|
+
const ok = await confirm({
|
|
224
|
+
message: L.confirmRegenerateAll.replace('{type}', typeName(exp)),
|
|
225
|
+
confirmLabel: L.confirm,
|
|
226
|
+
cancelLabel: L.cancel,
|
|
227
|
+
variant: 'primary',
|
|
228
|
+
})
|
|
229
|
+
if (!ok) return
|
|
230
|
+
run(() => props.api.post('/admin/pdfs/regenerate-all', { type: exp.type }))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function deleteAll(exp: ExportInfo) {
|
|
234
|
+
const ok = await confirm({
|
|
235
|
+
message: L.confirmDeleteAll.replace('{type}', typeName(exp)),
|
|
236
|
+
confirmLabel: L.deleteAll,
|
|
237
|
+
cancelLabel: L.cancel,
|
|
238
|
+
variant: 'danger',
|
|
239
|
+
})
|
|
240
|
+
if (!ok) return
|
|
241
|
+
run(() => props.api.delete('/admin/pdfs', { params: { type: exp.type } }))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Acciones del elemento del panel ---
|
|
245
|
+
|
|
246
|
+
function generate(type: string, sourceId: number | null) {
|
|
247
|
+
run(() =>
|
|
248
|
+
props.api.post('/admin/pdfs/generate', {
|
|
249
|
+
type,
|
|
250
|
+
source_id: sourceId ?? undefined,
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function regenerate(pdf: PdfRow) {
|
|
256
|
+
run(() => props.api.post(`/admin/pdfs/${pdf.id}/regenerate`))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function del(pdf: PdfRow) {
|
|
260
|
+
const ok = await confirm({
|
|
261
|
+
message: L.confirmDelete.replace('{name}', `${pdf.filename} (${pdf.locale.toUpperCase()})`),
|
|
262
|
+
confirmLabel: L.delete,
|
|
263
|
+
cancelLabel: L.cancel,
|
|
264
|
+
variant: 'danger',
|
|
265
|
+
})
|
|
266
|
+
if (!ok) return
|
|
267
|
+
run(() => props.api.delete(`/admin/pdfs/${pdf.id}`))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function statusLabel(pdf: PdfRow): string {
|
|
271
|
+
if (pdf.status === 'ready') return L.statusReady
|
|
272
|
+
if (pdf.status === 'failed') return L.statusFailed
|
|
273
|
+
return L.statusPending
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function statusClass(pdf: PdfRow): string {
|
|
277
|
+
if (pdf.status === 'ready') return 'is-ok'
|
|
278
|
+
if (pdf.status === 'failed') return 'is-failed'
|
|
279
|
+
return ''
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatDate(iso: string | null): string {
|
|
283
|
+
return iso ? new Date(iso).toLocaleString() : ''
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
onMounted(loadCatalog)
|
|
287
|
+
defineExpose({ refreshAll })
|
|
288
|
+
</script>
|
|
289
|
+
|
|
290
|
+
<template>
|
|
291
|
+
<div class="pdf-manager manager-container">
|
|
292
|
+
<div class="manager-bar">
|
|
293
|
+
<BaseButton variant="secondary" :disabled="loading || busy" @click="refreshAll">
|
|
294
|
+
<template #icon><RefreshCw :size="16" /></template>
|
|
295
|
+
{{ L.refresh }}
|
|
296
|
+
</BaseButton>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<div class="manager-grid">
|
|
300
|
+
<ManagerCard
|
|
301
|
+
v-for="exp in exports"
|
|
302
|
+
:key="exp.type"
|
|
303
|
+
:title="typeName(exp)"
|
|
304
|
+
:chip="exp.layout"
|
|
305
|
+
:active="activeType === exp.type"
|
|
306
|
+
@select="select(exp)"
|
|
307
|
+
>
|
|
308
|
+
<!-- Resumen como el de las previews: total + listos por idioma -->
|
|
309
|
+
<template #meta>
|
|
310
|
+
<span class="manager-stat"
|
|
311
|
+
>{{ L.total }} <strong>{{ exp.stats.total }}</strong></span
|
|
312
|
+
>
|
|
313
|
+
<span
|
|
314
|
+
v-for="(ready, locale) in exp.stats.locales"
|
|
315
|
+
:key="locale"
|
|
316
|
+
:class="['chip', ready === exp.stats.total ? 'is-ok' : 'is-missing']"
|
|
317
|
+
>{{ String(locale).toUpperCase() }} {{ ready }}/{{ exp.stats.total }}</span
|
|
318
|
+
>
|
|
319
|
+
</template>
|
|
320
|
+
</ManagerCard>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<!-- Panel derecho: acciones | separador | PDF del export activo -->
|
|
324
|
+
<Teleport defer to="#right-sidebar-target">
|
|
325
|
+
<div class="manager-panel">
|
|
326
|
+
<p v-if="!activeExport" class="manager-panel__empty">{{ L.panelEmpty }}</p>
|
|
327
|
+
<template v-else>
|
|
328
|
+
<p class="manager-panel__kicker">{{ typeName(activeExport) }}</p>
|
|
329
|
+
|
|
330
|
+
<!-- Acciones del export, PRIMERO (mismas que las previews) -->
|
|
331
|
+
<div class="manager-detail__actions">
|
|
332
|
+
<BaseButton
|
|
333
|
+
:disabled="busy || !hasMissing(activeExport)"
|
|
334
|
+
@click="generateMissing(activeExport)"
|
|
335
|
+
>
|
|
336
|
+
<template #icon><FilePlus :size="14" /></template>
|
|
337
|
+
{{ L.generateMissing }}
|
|
338
|
+
</BaseButton>
|
|
339
|
+
<BaseButton variant="info" :disabled="busy" @click="regenerateAll(activeExport)">
|
|
340
|
+
<template #icon><RefreshCw :size="14" /></template>
|
|
341
|
+
{{ L.regenerateAll }}
|
|
342
|
+
</BaseButton>
|
|
343
|
+
<BaseButton variant="danger" :disabled="busy" @click="deleteAll(activeExport)">
|
|
344
|
+
<template #icon><Trash2 :size="14" /></template>
|
|
345
|
+
{{ L.deleteAll }}
|
|
346
|
+
</BaseButton>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<hr class="manager-panel__divider" />
|
|
350
|
+
|
|
351
|
+
<!-- Por entidad: combobox (con buscador) de la entidad dueña -->
|
|
352
|
+
<SearchSelect
|
|
353
|
+
v-if="!activeExport.global"
|
|
354
|
+
:model-value="selectedSourceId"
|
|
355
|
+
:options="filteredSources"
|
|
356
|
+
:placeholder="L.selectSource"
|
|
357
|
+
:search-placeholder="L.searchPlaceholder"
|
|
358
|
+
:no-results="L.noResults"
|
|
359
|
+
@update:model-value="(id) => selectSource(Number(id))"
|
|
360
|
+
@search="(q) => (sourceSearch = q)"
|
|
361
|
+
/>
|
|
362
|
+
|
|
363
|
+
<div v-if="panelRows" class="manager-detail">
|
|
364
|
+
<div v-if="!activeExport.global" class="manager-detail__actions">
|
|
365
|
+
<BaseButton :disabled="busy" @click="generate(activeExport.type, selectedSourceId)">
|
|
366
|
+
<template #icon><FilePlus :size="14" /></template>
|
|
367
|
+
{{ L.generate }}
|
|
368
|
+
</BaseButton>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<p v-if="!panelRows.length" class="manager-panel__empty">{{ L.empty }}</p>
|
|
372
|
+
|
|
373
|
+
<div v-for="pdf in panelRows" :key="pdf.id" class="pdf-entry">
|
|
374
|
+
<div class="pdf-entry__head">
|
|
375
|
+
<span class="pdf-entry__locale">{{ pdf.locale.toUpperCase() }}</span>
|
|
376
|
+
<span :class="['chip', statusClass(pdf)]">{{ statusLabel(pdf) }}</span>
|
|
377
|
+
<span class="pdf-entry__buttons">
|
|
378
|
+
<a
|
|
379
|
+
v-if="pdf.url"
|
|
380
|
+
class="icon-btn icon-btn--success"
|
|
381
|
+
:href="pdf.url"
|
|
382
|
+
target="_blank"
|
|
383
|
+
rel="noopener"
|
|
384
|
+
:title="L.download"
|
|
385
|
+
><Download :size="16"
|
|
386
|
+
/></a>
|
|
387
|
+
<IconButton variant="info" :title="L.regenerate" @click="regenerate(pdf)"
|
|
388
|
+
><RefreshCw :size="16"
|
|
389
|
+
/></IconButton>
|
|
390
|
+
<IconButton variant="danger" :title="L.delete" @click="del(pdf)"
|
|
391
|
+
><Trash2 :size="16"
|
|
392
|
+
/></IconButton>
|
|
393
|
+
</span>
|
|
394
|
+
</div>
|
|
395
|
+
<p v-if="pdf.generated_at" class="pdf-entry__meta">
|
|
396
|
+
{{ L.generatedAt }}: {{ formatDate(pdf.generated_at) }}
|
|
397
|
+
</p>
|
|
398
|
+
<p v-if="pdf.status === 'failed' && pdf.error" class="pdf-entry__error">
|
|
399
|
+
{{ pdf.error }}
|
|
400
|
+
</p>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</template>
|
|
404
|
+
</div>
|
|
405
|
+
</Teleport>
|
|
406
|
+
</div>
|
|
407
|
+
</template>
|