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