@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,398 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
import type { AxiosInstance } from 'axios'
|
|
4
|
+
import { Eraser, ImageOff, ImagePlus, RefreshCw, Trash2 } from '@lucide/vue'
|
|
5
|
+
import { BaseButton, SearchSelect, useConfirm, useToast } from '@edc-motor/ui'
|
|
6
|
+
import ManagerCard from '../components/ManagerCard.vue'
|
|
7
|
+
import { useRightSidebar } from '../composables/useRightSidebar'
|
|
8
|
+
|
|
9
|
+
// Gestor de previews PNG (doc 01/08), mobile-first. Una tarjeta FIJA por tipo
|
|
10
|
+
// (sin colapsar, sin listas interminables): estadísticas —total y generadas
|
|
11
|
+
// por idioma— y los botones "de todas" (generar faltantes / regenerar todo /
|
|
12
|
+
// borrar todo). Al seleccionar una tarjeta, el panel derecho (patrón kontuan)
|
|
13
|
+
// muestra un COMBOBOX (select desplegable con buscador) de sus elementos —
|
|
14
|
+
// las listas largas viven dentro del desplegable —; el elegido enseña sus
|
|
15
|
+
// imágenes por idioma y sus acciones (generar faltantes / regenerar /
|
|
16
|
+
// borrar). Endpoints /admin/previews del motor.
|
|
17
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
18
|
+
|
|
19
|
+
export interface PreviewManagerLabels {
|
|
20
|
+
refresh: string
|
|
21
|
+
clean: string
|
|
22
|
+
total: string
|
|
23
|
+
generateMissing: string
|
|
24
|
+
regenerateAll: string
|
|
25
|
+
deleteAll: string
|
|
26
|
+
selectItem: string
|
|
27
|
+
searchPlaceholder: string
|
|
28
|
+
noResults: string
|
|
29
|
+
loadMore: string
|
|
30
|
+
panelEmpty: string
|
|
31
|
+
detailTitle: string
|
|
32
|
+
itemGenerateMissing: string
|
|
33
|
+
itemRegenerate: string
|
|
34
|
+
itemDelete: string
|
|
35
|
+
confirmRegenerateAll: string
|
|
36
|
+
confirmDeleteAll: string
|
|
37
|
+
confirmDeleteItem: string
|
|
38
|
+
confirmClean: string
|
|
39
|
+
confirm: string
|
|
40
|
+
cancel: string
|
|
41
|
+
error: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaultLabels: PreviewManagerLabels = {
|
|
45
|
+
refresh: 'Actualizar',
|
|
46
|
+
clean: 'Limpiar huérfanos',
|
|
47
|
+
total: 'Total',
|
|
48
|
+
generateMissing: 'Generar faltantes',
|
|
49
|
+
regenerateAll: 'Regenerar todo',
|
|
50
|
+
deleteAll: 'Borrar todo',
|
|
51
|
+
selectItem: 'Elige un elemento…',
|
|
52
|
+
searchPlaceholder: 'Buscar…',
|
|
53
|
+
noResults: 'Sin resultados.',
|
|
54
|
+
loadMore: 'Cargar más',
|
|
55
|
+
panelEmpty: 'Selecciona una tarjeta para gestionar sus elementos.',
|
|
56
|
+
detailTitle: 'Elementos',
|
|
57
|
+
itemGenerateMissing: 'Generar faltantes',
|
|
58
|
+
itemRegenerate: 'Regenerar',
|
|
59
|
+
itemDelete: 'Borrar PNG',
|
|
60
|
+
confirmRegenerateAll: '¿Regenerar TODAS las previews de {type}?',
|
|
61
|
+
confirmDeleteAll: '¿Borrar TODAS las previews de {type}?',
|
|
62
|
+
confirmDeleteItem: '¿Borrar los PNG de "{name}"?',
|
|
63
|
+
confirmClean: '¿Eliminar los ficheros de preview huérfanos?',
|
|
64
|
+
confirm: 'Confirmar',
|
|
65
|
+
cancel: 'Cancelar',
|
|
66
|
+
error: 'No se ha podido completar la acción.',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const props = withDefaults(
|
|
70
|
+
defineProps<{
|
|
71
|
+
api: AxiosInstance
|
|
72
|
+
labels?: Partial<PreviewManagerLabels>
|
|
73
|
+
/** Nombre traducido de cada tipo (clave del registro => etiqueta). */
|
|
74
|
+
typeLabels?: Record<string, string>
|
|
75
|
+
}>(),
|
|
76
|
+
{ labels: () => ({}), typeLabels: () => ({}) },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const L = reactive({ ...defaultLabels, ...props.labels }) as PreviewManagerLabels
|
|
80
|
+
|
|
81
|
+
const toast = useToast()
|
|
82
|
+
const { confirm } = useConfirm()
|
|
83
|
+
|
|
84
|
+
// El selector y el detalle viven en el panel derecho del layout.
|
|
85
|
+
const sidebar = useRightSidebar()
|
|
86
|
+
sidebar.useRegister(L.detailTitle)
|
|
87
|
+
|
|
88
|
+
interface TypeStatus {
|
|
89
|
+
key: string
|
|
90
|
+
model: string
|
|
91
|
+
total: number
|
|
92
|
+
complete: number
|
|
93
|
+
pending: number
|
|
94
|
+
locales: Record<string, number>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PreviewItem {
|
|
98
|
+
id: number
|
|
99
|
+
label: string
|
|
100
|
+
previews: Record<string, string | null>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const types = ref<TypeStatus[]>([])
|
|
104
|
+
const loading = ref(true)
|
|
105
|
+
const busy = ref(false)
|
|
106
|
+
|
|
107
|
+
/** Etiqueta traducida del tipo (fallback: nombre del modelo). */
|
|
108
|
+
function typeName(type: TypeStatus): string {
|
|
109
|
+
return props.typeLabels[type.key] ?? type.model
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Tipo activo (tarjeta seleccionada) y su selector en el panel.
|
|
113
|
+
const activeType = ref<string | null>(null)
|
|
114
|
+
const search = ref('')
|
|
115
|
+
const items = ref<PreviewItem[]>([])
|
|
116
|
+
const page = ref({ current: 1, last: 1 })
|
|
117
|
+
const selectedId = ref<number | null>(null)
|
|
118
|
+
|
|
119
|
+
const activeStatus = computed(() => types.value.find((t) => t.key === activeType.value) ?? null)
|
|
120
|
+
const selectedItem = computed(() => items.value.find((i) => i.id === selectedId.value) ?? null)
|
|
121
|
+
const missingLocales = computed(() =>
|
|
122
|
+
selectedItem.value
|
|
123
|
+
? Object.entries(selectedItem.value.previews)
|
|
124
|
+
.filter(([, url]) => !url)
|
|
125
|
+
.map(([locale]) => locale)
|
|
126
|
+
: [],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async function loadStatus() {
|
|
130
|
+
loading.value = true
|
|
131
|
+
try {
|
|
132
|
+
const { data } = await props.api.get('/admin/previews')
|
|
133
|
+
types.value = data.data
|
|
134
|
+
} catch {
|
|
135
|
+
toast.danger(L.error)
|
|
136
|
+
} finally {
|
|
137
|
+
loading.value = false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function loadItems(pageNumber = 1) {
|
|
142
|
+
if (!activeType.value) return
|
|
143
|
+
try {
|
|
144
|
+
const { data } = await props.api.get(`/admin/previews/${activeType.value}/items`, {
|
|
145
|
+
params: { page: pageNumber, q: search.value || undefined },
|
|
146
|
+
})
|
|
147
|
+
items.value = pageNumber === 1 ? data.data : [...items.value, ...data.data]
|
|
148
|
+
page.value = { current: data.meta.current_page, last: data.meta.last_page }
|
|
149
|
+
} catch {
|
|
150
|
+
toast.danger(L.error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function select(type: TypeStatus) {
|
|
155
|
+
if (activeType.value !== type.key) {
|
|
156
|
+
activeType.value = type.key
|
|
157
|
+
search.value = ''
|
|
158
|
+
items.value = []
|
|
159
|
+
selectedId.value = null
|
|
160
|
+
loadItems()
|
|
161
|
+
}
|
|
162
|
+
sidebar.reveal()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Búsqueda del combobox (debounce en el componente): consulta al servidor. */
|
|
166
|
+
function onSearch(q: string) {
|
|
167
|
+
search.value = q
|
|
168
|
+
loadItems()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function refreshAll() {
|
|
172
|
+
await loadStatus()
|
|
173
|
+
if (activeType.value) await loadItems()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Envuelve una acción: bloquea botones, toast con el mensaje del servidor. */
|
|
177
|
+
async function run(action: () => Promise<{ data: { message?: string } }>, after?: () => void) {
|
|
178
|
+
busy.value = true
|
|
179
|
+
try {
|
|
180
|
+
const { data } = await action()
|
|
181
|
+
if (data.message) toast.success(data.message)
|
|
182
|
+
after?.()
|
|
183
|
+
} catch {
|
|
184
|
+
toast.danger(L.error)
|
|
185
|
+
} finally {
|
|
186
|
+
busy.value = false
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Acciones "de todas" (tarjeta) ---
|
|
191
|
+
|
|
192
|
+
function generateType(type: TypeStatus) {
|
|
193
|
+
run(() => props.api.post(`/admin/previews/${type.key}/generate`))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function regenerateType(type: TypeStatus) {
|
|
197
|
+
const ok = await confirm({
|
|
198
|
+
message: L.confirmRegenerateAll.replace('{type}', typeName(type)),
|
|
199
|
+
confirmLabel: L.confirm,
|
|
200
|
+
cancelLabel: L.cancel,
|
|
201
|
+
variant: 'primary',
|
|
202
|
+
})
|
|
203
|
+
if (!ok) return
|
|
204
|
+
run(() => props.api.post(`/admin/previews/${type.key}/regenerate`))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function deleteType(type: TypeStatus) {
|
|
208
|
+
const ok = await confirm({
|
|
209
|
+
message: L.confirmDeleteAll.replace('{type}', typeName(type)),
|
|
210
|
+
confirmLabel: L.deleteAll,
|
|
211
|
+
cancelLabel: L.cancel,
|
|
212
|
+
variant: 'danger',
|
|
213
|
+
})
|
|
214
|
+
if (!ok) return
|
|
215
|
+
run(
|
|
216
|
+
() => props.api.delete(`/admin/previews/${type.key}`),
|
|
217
|
+
() => refreshAll(),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function cleanOrphans() {
|
|
222
|
+
const ok = await confirm({
|
|
223
|
+
message: L.confirmClean,
|
|
224
|
+
confirmLabel: L.clean,
|
|
225
|
+
cancelLabel: L.cancel,
|
|
226
|
+
variant: 'danger',
|
|
227
|
+
})
|
|
228
|
+
if (!ok) return
|
|
229
|
+
run(() => props.api.post('/admin/previews/clean'))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- Acciones del elemento elegido (panel) ---
|
|
233
|
+
|
|
234
|
+
function regenerateItem() {
|
|
235
|
+
if (!activeType.value || !selectedId.value) return
|
|
236
|
+
run(
|
|
237
|
+
() => props.api.post(`/admin/previews/${activeType.value}/${selectedId.value}/regenerate`),
|
|
238
|
+
() => refreshAll(),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Encola solo los idiomas que faltan del elemento elegido. */
|
|
243
|
+
async function generateMissingItem() {
|
|
244
|
+
if (!activeType.value || !selectedId.value) return
|
|
245
|
+
busy.value = true
|
|
246
|
+
let message: string | undefined
|
|
247
|
+
try {
|
|
248
|
+
for (const locale of missingLocales.value) {
|
|
249
|
+
const { data } = await props.api.post(
|
|
250
|
+
`/admin/previews/${activeType.value}/${selectedId.value}/regenerate`,
|
|
251
|
+
{ locale },
|
|
252
|
+
)
|
|
253
|
+
message = data.message ?? message
|
|
254
|
+
}
|
|
255
|
+
if (message) toast.success(message)
|
|
256
|
+
await refreshAll()
|
|
257
|
+
} catch {
|
|
258
|
+
toast.danger(L.error)
|
|
259
|
+
} finally {
|
|
260
|
+
busy.value = false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function deleteItem() {
|
|
265
|
+
if (!activeType.value || !selectedItem.value) return
|
|
266
|
+
const ok = await confirm({
|
|
267
|
+
message: L.confirmDeleteItem.replace('{name}', selectedItem.value.label),
|
|
268
|
+
confirmLabel: L.itemDelete,
|
|
269
|
+
cancelLabel: L.cancel,
|
|
270
|
+
variant: 'danger',
|
|
271
|
+
})
|
|
272
|
+
if (!ok) return
|
|
273
|
+
run(
|
|
274
|
+
() => props.api.delete(`/admin/previews/${activeType.value}/${selectedId.value}`),
|
|
275
|
+
() => refreshAll(),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onMounted(loadStatus)
|
|
280
|
+
defineExpose({ refreshAll })
|
|
281
|
+
</script>
|
|
282
|
+
|
|
283
|
+
<template>
|
|
284
|
+
<div class="preview-manager manager-container">
|
|
285
|
+
<div class="manager-bar">
|
|
286
|
+
<BaseButton variant="secondary" :disabled="loading || busy" @click="refreshAll">
|
|
287
|
+
<template #icon><RefreshCw :size="16" /></template>
|
|
288
|
+
{{ L.refresh }}
|
|
289
|
+
</BaseButton>
|
|
290
|
+
<BaseButton variant="danger" :disabled="busy" @click="cleanOrphans">
|
|
291
|
+
<template #icon><Eraser :size="16" /></template>
|
|
292
|
+
{{ L.clean }}
|
|
293
|
+
</BaseButton>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="manager-grid">
|
|
297
|
+
<ManagerCard
|
|
298
|
+
v-for="type in types"
|
|
299
|
+
:key="type.key"
|
|
300
|
+
:title="typeName(type)"
|
|
301
|
+
:active="activeType === type.key"
|
|
302
|
+
@select="select(type)"
|
|
303
|
+
>
|
|
304
|
+
<!-- Total + generadas por idioma, de un vistazo -->
|
|
305
|
+
<template #meta>
|
|
306
|
+
<span class="manager-stat"
|
|
307
|
+
>{{ L.total }} <strong>{{ type.total }}</strong></span
|
|
308
|
+
>
|
|
309
|
+
<span
|
|
310
|
+
v-for="(count, locale) in type.locales"
|
|
311
|
+
:key="locale"
|
|
312
|
+
:class="['chip', count === type.total ? 'is-ok' : 'is-missing']"
|
|
313
|
+
>{{ String(locale).toUpperCase() }} {{ count }}/{{ type.total }}</span
|
|
314
|
+
>
|
|
315
|
+
</template>
|
|
316
|
+
</ManagerCard>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<!-- Panel derecho: selector con buscador + detalle del elemento -->
|
|
320
|
+
<Teleport defer to="#right-sidebar-target">
|
|
321
|
+
<div class="manager-panel">
|
|
322
|
+
<p v-if="!activeStatus" class="manager-panel__empty">{{ L.panelEmpty }}</p>
|
|
323
|
+
<template v-else>
|
|
324
|
+
<p class="manager-panel__kicker">{{ typeName(activeStatus) }}</p>
|
|
325
|
+
|
|
326
|
+
<!-- Acciones PRIMERO; después, secciones separadas (patrón panel) -->
|
|
327
|
+
<div class="manager-detail__actions">
|
|
328
|
+
<BaseButton
|
|
329
|
+
:disabled="busy || activeStatus.pending === 0"
|
|
330
|
+
@click="generateType(activeStatus)"
|
|
331
|
+
>
|
|
332
|
+
<template #icon><ImagePlus :size="14" /></template>
|
|
333
|
+
{{ L.generateMissing }}
|
|
334
|
+
</BaseButton>
|
|
335
|
+
<BaseButton variant="info" :disabled="busy" @click="regenerateType(activeStatus)">
|
|
336
|
+
<template #icon><RefreshCw :size="14" /></template>
|
|
337
|
+
{{ L.regenerateAll }}
|
|
338
|
+
</BaseButton>
|
|
339
|
+
<BaseButton variant="danger" :disabled="busy" @click="deleteType(activeStatus)">
|
|
340
|
+
<template #icon><Trash2 :size="14" /></template>
|
|
341
|
+
{{ L.deleteAll }}
|
|
342
|
+
</BaseButton>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<hr class="manager-panel__divider" />
|
|
346
|
+
|
|
347
|
+
<SearchSelect
|
|
348
|
+
:model-value="selectedId"
|
|
349
|
+
:options="items.map((i) => ({ id: i.id, label: i.label }))"
|
|
350
|
+
:placeholder="L.selectItem"
|
|
351
|
+
:search-placeholder="L.searchPlaceholder"
|
|
352
|
+
:no-results="L.noResults"
|
|
353
|
+
:load-more-label="L.loadMore"
|
|
354
|
+
:can-load-more="page.current < page.last"
|
|
355
|
+
@update:model-value="(id) => (selectedId = Number(id))"
|
|
356
|
+
@search="onSearch"
|
|
357
|
+
@load-more="loadItems(page.current + 1)"
|
|
358
|
+
/>
|
|
359
|
+
|
|
360
|
+
<div v-if="selectedItem" class="manager-detail">
|
|
361
|
+
<h3 class="manager-detail__title">{{ selectedItem.label }}</h3>
|
|
362
|
+
|
|
363
|
+
<div class="manager-detail__figures">
|
|
364
|
+
<figure
|
|
365
|
+
v-for="(url, locale) in selectedItem.previews"
|
|
366
|
+
:key="locale"
|
|
367
|
+
:class="['manager-detail__figure', { 'is-missing': !url }]"
|
|
368
|
+
>
|
|
369
|
+
<img v-if="url" :src="url" :alt="`${selectedItem.label} ${locale}`" />
|
|
370
|
+
<span v-else class="manager-detail__hole"><ImageOff :size="18" /></span>
|
|
371
|
+
<figcaption>{{ String(locale).toUpperCase() }}</figcaption>
|
|
372
|
+
</figure>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="manager-detail__actions">
|
|
376
|
+
<BaseButton
|
|
377
|
+
v-if="missingLocales.length"
|
|
378
|
+
:disabled="busy"
|
|
379
|
+
@click="generateMissingItem"
|
|
380
|
+
>
|
|
381
|
+
<template #icon><ImagePlus :size="14" /></template>
|
|
382
|
+
{{ L.itemGenerateMissing }}
|
|
383
|
+
</BaseButton>
|
|
384
|
+
<BaseButton variant="info" :disabled="busy" @click="regenerateItem">
|
|
385
|
+
<template #icon><RefreshCw :size="14" /></template>
|
|
386
|
+
{{ L.itemRegenerate }}
|
|
387
|
+
</BaseButton>
|
|
388
|
+
<BaseButton variant="danger" :disabled="busy" @click="deleteItem">
|
|
389
|
+
<template #icon><Trash2 :size="14" /></template>
|
|
390
|
+
{{ L.itemDelete }}
|
|
391
|
+
</BaseButton>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</template>
|
|
395
|
+
</div>
|
|
396
|
+
</Teleport>
|
|
397
|
+
</div>
|
|
398
|
+
</template>
|