feat: add generic tabular records and history subtab for plugins

main 1.21.0
Juan Manuel Bardallo 2026-10-26 19:59:53 +01:00
parent 763a49f9cc
commit c178c21031
5 changed files with 870 additions and 115 deletions

View File

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

View File

@ -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); }
}

View File

@ -24,129 +24,283 @@
</div>
@if (plugin.enabled) {
<div class="settings-form-wrapper">
<div class="section-header-row">
<div class="section-title-box">
<mat-icon class="section-icon">tune</mat-icon>
<h3 class="section-heading">Parámetros de Configuración del Plugin</h3>
</div>
<div class="form-actions-bar top-actions">
<button mat-stroked-button color="warn" (click)="reset()" class="btn-reset">
<mat-icon>restart_alt</mat-icon>
Restaurar por defecto
@if (plugin.recordsEndpoint) {
<div class="subtabs-navigation-bar">
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 0"
(click)="setSubTab(0)">
<mat-icon class="subtab-nav-icon">tune</mat-icon>
<span>Configuración</span>
</button>
<button mat-raised-button color="primary" (click)="save()" class="btn-save">
<mat-icon>save</mat-icon>
Guardar Configuración
<button
type="button"
class="subtab-nav-item"
[class.active]="activeSubTab === 1"
(click)="setSubTab(1)">
<mat-icon class="subtab-nav-icon">history</mat-icon>
<span>Historial y Registros</span>
@if (recordsTotal > 0) {
<span class="subtab-counter-badge">{{ recordsTotal }}</span>
}
</button>
</div>
</div>
}
<p class="section-subheading">
Ajusta el comportamiento y las opciones de ejecución. Los cambios se aplicarán inmediatamente a las nuevas órdenes enviadas a los puestos.
</p>
<div class="form-fields-grid">
@for (field of schema; track field.key) {
<div class="form-field-card" [class.full-width]="field.type === 'textarea'">
<div class="field-meta">
<label class="field-label" [for]="'field-' + field.key">{{ field.labelKey ? (field.labelKey | translate) : field.label }}</label>
@if (field.description) {
<span class="field-description">{{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }}</span>
}
@if (activeSubTab === 0) {
<div class="settings-form-wrapper">
<div class="section-header-row">
<div class="section-title-box">
<mat-icon class="section-icon">tune</mat-icon>
<h3 class="section-heading">Parámetros de Configuración del Plugin</h3>
</div>
<div class="field-control-container">
@switch (field.type) {
@case ('select') {
<mat-form-field appearance="outline" class="w-100">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
@for (opt of field.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="toggle-wrapper">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]">
{{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }}
</mat-slide-toggle>
</div>
}
@case ('number') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('text') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('textarea') {
<div class="code-editor-box">
<div class="code-editor-header">
<div class="code-header-left">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
<span class="code-lang-badge">PowerShell</span>
</div>
<div class="code-header-actions">
@if (field.action) {
<button type="button" class="code-action-btn action-helper" (click)="runFieldAction(field)" [title]="field.action.label">
<mat-icon class="action-btn-icon">{{ field.action.icon || 'auto_fix_high' }}</mat-icon>
<span>{{ field.action.label }}</span>
</button>
}
<button type="button" class="code-action-btn" (click)="copyToClipboard(configData[field.key])" title="Copiar script">
<mat-icon class="action-btn-icon">content_copy</mat-icon>
<span>Copiar</span>
</button>
<button type="button" class="code-action-btn" [class.active]="isEditingScript(field.key)" (click)="toggleScriptEdit(field.key)">
<mat-icon class="action-btn-icon">{{ isEditingScript(field.key) ? 'visibility' : 'edit' }}</mat-icon>
<span>{{ isEditingScript(field.key) ? 'Ver coloreado' : 'Editar código' }}</span>
</button>
</div>
</div>
@if (!isEditingScript(field.key)) {
<div class="code-preview-scroll">
<div class="code-lines-table">
@for (line of getScriptLines(configData[field.key]); track $index) {
<div class="code-line-row">
<span class="line-number">{{ $index + 1 }}</span>
<span class="line-code" [innerHTML]="line"></span>
</div>
}
</div>
</div>
} @else {
<div class="code-textarea-wrapper">
<textarea
[id]="'field-' + field.key"
class="code-raw-textarea"
rows="9"
[(ngModel)]="configData[field.key]"
spellcheck="false"
></textarea>
</div>
}
</div>
}
}
<div class="form-actions-bar top-actions">
<button mat-stroked-button color="warn" (click)="reset()" class="btn-reset">
<mat-icon>restart_alt</mat-icon>
Restaurar por defecto
</button>
<button mat-raised-button color="primary" (click)="save()" class="btn-save">
<mat-icon>save</mat-icon>
Guardar Configuración
</button>
</div>
</div>
}
</div>
</div>
} @else {
<p class="section-subheading">
Ajusta el comportamiento y las opciones de ejecución. Los cambios se aplicarán inmediatamente a las nuevas órdenes enviadas a los puestos.
</p>
<div class="form-fields-grid">
@for (field of schema; track field.key) {
<div class="form-field-card" [class.full-width]="field.type === 'textarea'">
<div class="field-meta">
<label class="field-label" [for]="'field-' + field.key">{{ field.labelKey ? (field.labelKey | translate) : field.label }}</label>
@if (field.description) {
<span class="field-description">{{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }}</span>
}
</div>
<div class="field-control-container">
@switch (field.type) {
@case ('select') {
<mat-form-field appearance="outline" class="w-100">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
@for (opt of field.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="toggle-wrapper">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]">
{{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }}
</mat-slide-toggle>
</div>
}
@case ('number') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('text') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('textarea') {
<div class="code-editor-box">
<div class="code-editor-header">
<div class="code-header-left">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
<span class="code-lang-badge">PowerShell</span>
</div>
<div class="code-header-actions">
@if (field.action) {
<button type="button" class="code-action-btn action-helper" (click)="runFieldAction(field)" [title]="field.action.label">
<mat-icon class="action-btn-icon">{{ field.action.icon || 'auto_fix_high' }}</mat-icon>
<span>{{ field.action.label }}</span>
</button>
}
<button type="button" class="code-action-btn" (click)="copyToClipboard(configData[field.key])" title="Copiar script">
<mat-icon class="action-btn-icon">content_copy</mat-icon>
<span>Copiar</span>
</button>
<button type="button" class="code-action-btn" [class.active]="isEditingScript(field.key)" (click)="toggleScriptEdit(field.key)">
<mat-icon class="action-btn-icon">{{ isEditingScript(field.key) ? 'visibility' : 'edit' }}</mat-icon>
<span>{{ isEditingScript(field.key) ? 'Ver coloreado' : 'Editar código' }}</span>
</button>
</div>
</div>
@if (!isEditingScript(field.key)) {
<div class="code-preview-scroll">
<div class="code-lines-table">
@for (line of getScriptLines(configData[field.key]); track $index) {
<div class="code-line-row">
<span class="line-number">{{ $index + 1 }}</span>
<span class="line-code" [innerHTML]="line"></span>
</div>
}
</div>
</div>
} @else {
<div class="code-textarea-wrapper">
<textarea
[id]="'field-' + field.key"
class="code-raw-textarea"
rows="9"
[(ngModel)]="configData[field.key]"
spellcheck="false"
></textarea>
</div>
}
</div>
}
}
</div>
</div>
}
</div>
</div>
} @else if (activeSubTab === 1 && plugin.recordsEndpoint) {
<div class="records-wrapper">
<div class="records-header-row">
<div class="records-title-group">
<mat-icon class="records-header-icon">history_edu</mat-icon>
<div>
<h3 class="records-heading">Historial de Registros y Actividad</h3>
<p class="records-subheading">Registro cronológico de sesiones y eventos gestionados por {{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</p>
</div>
</div>
<div class="records-actions-group">
<div class="records-search-box">
<mat-icon class="search-icon">search</mat-icon>
<input
type="text"
class="records-search-input"
placeholder="Buscar por DNI, puesto o aula..."
[(ngModel)]="recordsSearch"
(keyup.enter)="onRecordsSearch()"
/>
@if (recordsSearch) {
<button type="button" class="clear-search-btn" (click)="clearRecordsSearch()" title="Limpiar búsqueda">
<mat-icon class="clear-icon">close</mat-icon>
</button>
}
</div>
<button mat-stroked-button color="primary" class="btn-records-action" (click)="loadRecords(recordsPage)" [disabled]="loadingRecords" title="Actualizar lista">
<mat-icon class="btn-icon" [class.spinning]="loadingRecords">refresh</mat-icon>
<span>Actualizar</span>
</button>
<button mat-raised-button color="accent" class="btn-records-action btn-export" (click)="exportCsv()" [disabled]="exportingCsv" title="Descargar como CSV">
<mat-icon class="btn-icon">download</mat-icon>
<span>Exportar CSV</span>
</button>
</div>
</div>
<div class="records-table-container">
@if (loadingRecords) {
<div class="records-loading-state">
<div class="loading-spinner"></div>
<p>Cargando registros...</p>
</div>
} @else if (records.length === 0) {
<div class="records-empty-state">
<mat-icon class="empty-state-icon">receipt_long</mat-icon>
<h4 class="empty-state-title">No hay registros disponibles</h4>
<p class="empty-state-text">
@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.
}
</p>
</div>
} @else {
<div class="table-responsive">
<table class="records-data-table">
<thead>
<tr>
@for (col of (plugin.recordsColumns || []); track col.key) {
<th>{{ col.labelKey ? (col.labelKey | translate) : col.label }}</th>
}
</tr>
</thead>
<tbody>
@for (row of records; track row.id || $index) {
<tr class="record-row">
@for (col of (plugin.recordsColumns || []); track col.key) {
<td>
@switch (col.key) {
@case ('dni') {
<span class="badge-dni">{{ row[col.key] || '-' }}</span>
}
@case ('end_reason') {
<span class="badge-reason" [ngClass]="getReasonClass(row[col.key])">
{{ formatReason(row[col.key]) }}
</span>
}
@case ('duration_minutes') {
<span class="badge-duration">{{ row[col.key] != null ? row[col.key] + ' min' : '-' }}</span>
}
@default {
<span class="cell-text">{{ row[col.key] ?? '-' }}</span>
}
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="records-pagination-bar">
<div class="pagination-info">
Mostrando {{ startIndex }} a {{ endIndex }} de {{ recordsTotal }} registros
</div>
<div class="pagination-controls">
<button
type="button"
class="page-btn"
[disabled]="recordsPage <= 1 || loadingRecords"
(click)="onPageChange(recordsPage - 1)">
<mat-icon>chevron_left</mat-icon>
<span>Anterior</span>
</button>
<span class="page-current">Pág. {{ recordsPage }} de {{ totalPages }}</span>
<button
type="button"
class="page-btn"
[disabled]="recordsPage >= totalPages || loadingRecords"
(click)="onPageChange(recordsPage + 1)">
<span>Siguiente</span>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</div>
}
</div>
</div>
}
} @else {
<div class="plugin-disabled-card">
<div class="plugin-disabled-icon-box">
<mat-icon class="plugin-disabled-icon">pause_circle_outline</mat-icon>

View File

@ -18,6 +18,14 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
schema: SchemaField[] = [];
configData: Record<string, any> = {};
editingScripts: Record<string, boolean> = {};
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<string, string> = {
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<any>(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';
}
}
}

View File

@ -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<string, Record<string, any>>;
schema: SchemaField[];