parent
763a49f9cc
commit
c178c21031
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue