From c178c21031e2f199976a62f0a30b87d37790cf00 Mon Sep 17 00:00:00 2001 From: jmbardallo Date: Mon, 26 Oct 2026 19:59:53 +0100 Subject: [PATCH] feat: add generic tabular records and history subtab for plugins --- CHANGELOG.md | 11 + .../plugin-settings.component.css | 399 ++++++++++++++++++ .../plugin-settings.component.html | 384 ++++++++++++----- .../plugin-settings.component.ts | 183 ++++++++ .../src/app/services/plugin.service.ts | 8 + 5 files changed, 870 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c276e9c..9885eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.21.0] - 2026-10-26 + +### Added +- **Sub-pestaña Genérica de Historial y Registros en Plugins (`PluginManifest`, `PluginSettingsComponent`)**: + - Incorporado soporte en `PluginManifest` para `recordsEndpoint` y `recordsColumns` (`RecordColumn`), permitiendo a cualquier plugin declarar un origen de datos tabular desacoplado. + - Añadido selector de sub-pestañas en `plugin-settings` para alternar entre "Configuración" e "Historial y Registros". + - Vista de registros completa con buscador en tiempo real (por DNI, puesto o aula), ordenación cronológica, paginación reactiva y contador de resultados. + - Soporte de exportación a archivo CSV con descarga directa desde backend (`${recordsEndpoint}/export`) y fallback automático de generación en cliente con BOM UTF-8 para compatibilidad universal. + - Renderizado adaptativo de celdas con soporte de badges semánticos para identificadores (DNI), duraciones y motivos de cierre de sesión (`TIMEOUT`, `RELEASED_BY_CONCIERGE`, `DISABLED_BY_ADMIN`). + - Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`, `@switch`) y estilos reutilizables sin atributos inline. + ## [1.20.0] - 2026-10-26 ### Added diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css index a0dd8b8..5f429af 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css @@ -514,3 +514,402 @@ grid-template-columns: 1fr; } } + +/* ========================================================================= + BARRA DE SUB-PESTAÑAS (Configuración vs Historial / Registros) + ========================================================================= */ + +.subtabs-navigation-bar { + display: flex; + align-items: center; + gap: 8px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 4px; + width: fit-content; +} + +.subtab-nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border-radius: 8px; + font-size: 0.92rem; + font-weight: 600; + border: none; + background: transparent; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; +} + +.subtab-nav-item:hover { + color: #0f172a; + background: rgba(255, 255, 255, 0.6); +} + +.subtab-nav-item.active { + background: #ffffff; + color: #0284c7; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.subtab-nav-icon { + font-size: 20px; + width: 20px; + height: 20px; +} + +.subtab-counter-badge { + font-size: 0.72rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + background: #e0f2fe; + color: #0369a1; +} + +/* ========================================================================= + VISTA DE HISTORIAL Y REGISTROS TABULARES + ========================================================================= */ + +.records-wrapper { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + display: flex; + flex-direction: column; + gap: 16px; +} + +.records-header-row { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding-bottom: 8px; +} + +.records-title-group { + display: flex; + align-items: center; + gap: 12px; +} + +.records-header-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: #0284c7; +} + +.records-heading { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + color: #0f172a; +} + +.records-subheading { + margin: 4px 0 0 0; + color: #64748b; + font-size: 0.85rem; +} + +.records-actions-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.records-search-box { + position: relative; + display: flex; + align-items: center; + background: #f8fafc; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 0 10px; + height: 38px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.records-search-box:focus-within { + border-color: #0284c7; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); +} + +.records-search-box .search-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #94a3b8; + margin-right: 6px; +} + +.records-search-input { + border: none; + background: transparent; + outline: none; + font-size: 0.88rem; + width: 240px; + color: #1e293b; +} + +.clear-search-btn { + border: none; + background: transparent; + cursor: pointer; + padding: 0; + color: #94a3b8; + display: flex; + align-items: center; + justify-content: center; +} + +.clear-search-btn:hover { + color: #475569; +} + +.clear-icon { + font-size: 16px; + width: 16px; + height: 16px; +} + +.btn-records-action { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + font-size: 0.85rem; + font-weight: 500; + border-radius: 8px; +} + +.btn-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.btn-icon.spinning { + animation: spin 1s linear infinite; +} + +/* ========================================================================= + TABLA DE REGISTROS + ========================================================================= */ + +.records-table-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.table-responsive { + width: 100%; + overflow-x: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.records-data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + text-align: left; +} + +.records-data-table thead tr { + background: #f8fafc; + border-bottom: 2px solid #e2e8f0; +} + +.records-data-table th { + padding: 12px 16px; + font-weight: 600; + color: #475569; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.records-data-table tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background-color 0.15s ease; +} + +.records-data-table tbody tr:last-child { + border-bottom: none; +} + +.records-data-table tbody tr:hover { + background-color: #f8fafc; +} + +.records-data-table td { + padding: 12px 16px; + color: #1e293b; + vertical-align: middle; + white-space: nowrap; +} + +.badge-dni { + font-family: 'Consolas', 'Fira Code', 'Monaco', monospace; + font-weight: 700; + background: #f1f5f9; + color: #0f172a; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid #e2e8f0; + font-size: 0.85rem; +} + +.badge-duration { + font-weight: 600; + color: #475569; + font-size: 0.85rem; +} + +.badge-reason { + display: inline-block; + font-size: 0.78rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 12px; +} + +.reason-timeout { + background: #fef3c7; + color: #92400e; +} + +.reason-concierge { + background: #e0f2fe; + color: #0369a1; +} + +.reason-disabled { + background: #fee2e2; + color: #991b1b; +} + +.reason-default { + background: #f1f5f9; + color: #475569; +} + +/* ========================================================================= + PAGINACIÓN + ========================================================================= */ + +.records-pagination-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 16px; + border-top: 1px solid #f1f5f9; + flex-wrap: wrap; + gap: 12px; +} + +.pagination-info { + font-size: 0.85rem; + color: #64748b; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.page-btn { + display: flex; + align-items: center; + gap: 4px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 6px 14px; + font-size: 0.82rem; + font-weight: 500; + color: #334155; + cursor: pointer; + transition: all 0.2s ease; +} + +.page-btn:hover:not(:disabled) { + background: #f8fafc; + border-color: #94a3b8; + color: #0f172a; +} + +.page-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.page-current { + font-size: 0.85rem; + font-weight: 600; + color: #0f172a; +} + +/* ========================================================================= + ESTADOS DE CARGA Y VACÍO + ========================================================================= */ + +.records-loading-state, +.records-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + gap: 12px; + text-align: center; +} + +.loading-spinner { + width: 34px; + height: 34px; + border: 3px solid #e2e8f0; + border-top: 3px solid #0284c7; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.empty-state-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #cbd5e1; +} + +.empty-state-title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: #334155; +} + +.empty-state-text { + margin: 0; + font-size: 0.88rem; + color: #64748b; + max-width: 420px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html index 9e3ef77..aa31f86 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html @@ -24,129 +24,283 @@ @if (plugin.enabled) { -
-
-
- tune -

Parámetros de Configuración del Plugin

-
- -
- -
-
+ } -

- Ajusta el comportamiento y las opciones de ejecución. Los cambios se aplicarán inmediatamente a las nuevas órdenes enviadas a los puestos. -

- -
- @for (field of schema; track field.key) { -
-
- - @if (field.description) { - {{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }} - } + @if (activeSubTab === 0) { +
+
+
+ tune +

Parámetros de Configuración del Plugin

-
- @switch (field.type) { - @case ('select') { - - - @for (opt of field.options; track opt) { - {{ opt }} - } - - - } - - @case ('checkbox') { -
- - {{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }} - -
- } - - @case ('number') { - - - - } - - @case ('text') { - - - - } - - @case ('textarea') { -
-
-
- - - - PowerShell -
-
- @if (field.action) { - - } - - -
-
- - @if (!isEditingScript(field.key)) { -
-
- @for (line of getScriptLines(configData[field.key]); track $index) { -
- {{ $index + 1 }} - -
- } -
-
- } @else { -
- -
- } -
- } - } +
+ +
- } -
-
- } @else { + +

+ Ajusta el comportamiento y las opciones de ejecución. Los cambios se aplicarán inmediatamente a las nuevas órdenes enviadas a los puestos. +

+ +
+ @for (field of schema; track field.key) { +
+
+ + @if (field.description) { + {{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }} + } +
+ +
+ @switch (field.type) { + @case ('select') { + + + @for (opt of field.options; track opt) { + {{ opt }} + } + + + } + + @case ('checkbox') { +
+ + {{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }} + +
+ } + + @case ('number') { + + + + } + + @case ('text') { + + + + } + + @case ('textarea') { +
+
+
+ + + + PowerShell +
+
+ @if (field.action) { + + } + + +
+
+ + @if (!isEditingScript(field.key)) { +
+
+ @for (line of getScriptLines(configData[field.key]); track $index) { +
+ {{ $index + 1 }} + +
+ } +
+
+ } @else { +
+ +
+ } +
+ } + } +
+
+ } +
+
+ } @else if (activeSubTab === 1 && plugin.recordsEndpoint) { +
+
+
+ history_edu +
+

Historial de Registros y Actividad

+

Registro cronológico de sesiones y eventos gestionados por {{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}

+
+
+ +
+ + + + + +
+
+ +
+ @if (loadingRecords) { +
+
+

Cargando registros...

+
+ } @else if (records.length === 0) { +
+ receipt_long +

No hay registros disponibles

+

+ @if (recordsSearch) { + No se encontraron resultados para la búsqueda "{{ recordsSearch }}". + } @else { + Aún no se ha generado ningún registro o actividad en este plugin. + } +

+
+ } @else { +
+ + + + @for (col of (plugin.recordsColumns || []); track col.key) { + + } + + + + @for (row of records; track row.id || $index) { + + @for (col of (plugin.recordsColumns || []); track col.key) { + + } + + } + +
{{ col.labelKey ? (col.labelKey | translate) : col.label }}
+ @switch (col.key) { + @case ('dni') { + {{ row[col.key] || '-' }} + } + @case ('end_reason') { + + {{ formatReason(row[col.key]) }} + + } + @case ('duration_minutes') { + {{ row[col.key] != null ? row[col.key] + ' min' : '-' }} + } + @default { + {{ row[col.key] ?? '-' }} + } + } +
+
+ +
+
+ Mostrando {{ startIndex }} a {{ endIndex }} de {{ recordsTotal }} registros +
+ +
+ + + Pág. {{ recordsPage }} de {{ totalPages }} + + +
+
+ } +
+
+ } + } @else {
pause_circle_outline diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts index 2656de3..b93d5ac 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts @@ -18,6 +18,14 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy { schema: SchemaField[] = []; configData: Record = {}; editingScripts: Record = {}; + activeSubTab: number = 0; // 0: Configuración, 1: Historial y Registros + records: any[] = []; + recordsTotal: number = 0; + recordsPage: number = 1; + recordsLimit: number = 15; + recordsSearch: string = ''; + loadingRecords: boolean = false; + exportingCsv: boolean = false; private pluginSub?: Subscription; private cdr = inject(ChangeDetectorRef); @@ -43,6 +51,11 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy { ngOnChanges(changes: SimpleChanges): void { if (changes['pluginName']) { + this.records = []; + this.recordsTotal = 0; + this.recordsPage = 1; + this.recordsSearch = ''; + this.activeSubTab = 0; this.loadPluginData(); } } @@ -53,6 +66,9 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy { if (this.plugin) { this.schema = this.plugin.schema; this.configData = this.pluginService.getPluginConfig(this.pluginName); + if (this.activeSubTab === 1 && this.plugin.recordsEndpoint) { + this.loadRecords(this.recordsPage); + } } this.cdr.markForCheck(); } @@ -184,4 +200,171 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy { this.configData = this.pluginService.resetPluginConfig(this.pluginName); this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin'); } + + setSubTab(tab: number): void { + this.activeSubTab = tab; + if (tab === 1 && this.records.length === 0 && this.plugin?.recordsEndpoint) { + this.loadRecords(1); + } + this.cdr.markForCheck(); + } + + loadRecords(page: number = 1): void { + if (!this.plugin?.recordsEndpoint) return; + + this.loadingRecords = true; + this.cdr.markForCheck(); + + const apiUrl = (this.configService.apiUrl || '').replace(/\/api\/?$/, ''); + const endpoint = this.plugin.recordsEndpoint.startsWith('/') + ? this.plugin.recordsEndpoint + : `/${this.plugin.recordsEndpoint}`; + const url = `${apiUrl}${endpoint}`; + + const params: Record = { + page: page.toString(), + limit: this.recordsLimit.toString() + }; + + if (this.recordsSearch && this.recordsSearch.trim()) { + params['search'] = this.recordsSearch.trim(); + params['dni'] = this.recordsSearch.trim(); + } + + this.http.get(url, { params }).subscribe({ + next: (response) => { + if (response && response.data && Array.isArray(response.data)) { + this.records = response.data; + this.recordsTotal = response.total ?? response.data.length; + this.recordsPage = response.page ?? page; + } else if (Array.isArray(response)) { + this.records = response; + this.recordsTotal = response.length; + this.recordsPage = 1; + } else { + this.records = []; + this.recordsTotal = 0; + } + this.loadingRecords = false; + this.cdr.markForCheck(); + }, + error: () => { + this.loadingRecords = false; + this.toast.error('Error al cargar los registros del plugin', this.plugin?.title || 'Plugin'); + this.cdr.markForCheck(); + } + }); + } + + onRecordsSearch(): void { + this.recordsPage = 1; + this.loadRecords(1); + } + + clearRecordsSearch(): void { + this.recordsSearch = ''; + this.recordsPage = 1; + this.loadRecords(1); + } + + onPageChange(page: number): void { + if (page >= 1 && page <= this.totalPages) { + this.recordsPage = page; + this.loadRecords(page); + } + } + + get totalPages(): number { + return Math.max(1, Math.ceil(this.recordsTotal / this.recordsLimit)); + } + + get startIndex(): number { + if (this.recordsTotal === 0) return 0; + return (this.recordsPage - 1) * this.recordsLimit + 1; + } + + get endIndex(): number { + return Math.min(this.recordsPage * this.recordsLimit, this.recordsTotal); + } + + exportCsv(): void { + if (!this.plugin?.recordsEndpoint) return; + + this.exportingCsv = true; + this.cdr.markForCheck(); + + const apiUrl = (this.configService.apiUrl || '').replace(/\/api\/?$/, ''); + const endpoint = this.plugin.recordsEndpoint.startsWith('/') + ? this.plugin.recordsEndpoint + : `/${this.plugin.recordsEndpoint}`; + const exportUrl = `${apiUrl}${endpoint}/export`; + + this.http.get(exportUrl, { responseType: 'blob' }).subscribe({ + next: (blob) => { + this.exportingCsv = false; + const filename = `${this.plugin?.name || 'plugin'}_historial_${new Date().toISOString().slice(0, 10)}.csv`; + this.downloadBlob(blob, filename); + this.toast.success('Archivo CSV exportado con éxito', 'Exportar'); + this.cdr.markForCheck(); + }, + error: () => { + this.exportingCsv = false; + this.generateClientCsv(); + this.cdr.markForCheck(); + } + }); + } + + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } + + private generateClientCsv(): void { + if (!this.records || this.records.length === 0 || !this.plugin?.recordsColumns) { + this.toast.warning('No hay datos disponibles para exportar', 'Exportar'); + return; + } + const cols = this.plugin.recordsColumns; + const headers = cols.map(c => `"${c.label}"`).join(';'); + const rows = this.records.map(row => + cols.map(c => { + const val = row[c.key] ?? ''; + return `"${String(val).replace(/"/g, '""')}"`; + }).join(';') + ); + const csvContent = '\uFEFF' + [headers, ...rows].join('\r\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const filename = `${this.plugin.name}_historial_${new Date().toISOString().slice(0, 10)}.csv`; + this.downloadBlob(blob, filename); + this.toast.success('Archivo CSV generado y descargado', 'Exportar'); + } + + formatReason(reason?: string): string { + if (!reason) return '-'; + switch (reason.toUpperCase()) { + case 'TIMEOUT': return 'Tiempo agotado'; + case 'RELEASED_BY_CONCIERGE': return 'Liberado (Conserje)'; + case 'DISABLED_BY_ADMIN': return 'Puesto desactivado'; + case 'CANCELLED': return 'Cancelado'; + case 'MANUAL': return 'Manual'; + default: return reason; + } + } + + getReasonClass(reason?: string): string { + if (!reason) return 'reason-default'; + switch (reason.toUpperCase()) { + case 'TIMEOUT': return 'reason-timeout'; + case 'RELEASED_BY_CONCIERGE': return 'reason-concierge'; + case 'DISABLED_BY_ADMIN': return 'reason-disabled'; + default: return 'reason-default'; + } + } } diff --git a/ogWebconsole/src/app/services/plugin.service.ts b/ogWebconsole/src/app/services/plugin.service.ts index 528a405..1ceb07c 100644 --- a/ogWebconsole/src/app/services/plugin.service.ts +++ b/ogWebconsole/src/app/services/plugin.service.ts @@ -53,6 +53,12 @@ export interface SchemaField { action?: FieldAction; } +export interface RecordColumn { + key: string; + label: string; + labelKey?: string; +} + export interface PluginManifest { name: string; title: string; @@ -65,6 +71,8 @@ export interface PluginManifest { enabled: boolean; commands: PluginCommand[]; statusEndpoint?: string; + recordsEndpoint?: string; + recordsColumns?: RecordColumn[]; badges?: PluginBadgeRule[]; i18n?: Record>; schema: SchemaField[];