@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.
@@ -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>