refs #2947. New notes field in CLient entity
testing/ogGui-multibranch/pipeline/head There was a failure building this commit
Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit
Details
commit
9c7778abcb
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,4 +1,20 @@
|
|||
# Changelog
|
||||
## [1.3.1] - 2026-04-09
|
||||
### Added
|
||||
- Añadida información de la fecha de despliegue de una imagen en una particion.
|
||||
|
||||
## [1.3.0] - 2026-03-31
|
||||
### Added
|
||||
- Añadido comando "Refrescar" tanto en un cliente como en un grupo de clientes, de forma que se ejecuta el comando Actualizar en el ogAgent y se actualiza la información de las particiones del cliente y se recarga el menú del ogbrowser.
|
||||
|
||||
## [1.2.7] - 2026-03-31
|
||||
### Added
|
||||
- Rediseño de la plantilla para seleccion de particiones, se ha creado un componente compartido por la vista de crear imagen y deploy de imagenes.
|
||||
|
||||
## [1.2.6] - 2026-03-30
|
||||
### Added
|
||||
- Rediseño de la pantalla detalles de un equipo, en la partición CACHE, se muestra el listado de ficheros que contiene con el tamaño de cada uno.
|
||||
|
||||
## [1.2.5] - 2026-03-27
|
||||
### Fixed
|
||||
- Corregido error 401 Unauthorized en la edición de menús con nombres que contienen la palabra "login". Se ha refinado el interceptor para aplicar el prefijo "Bearer" de forma selectiva mediante validación exacta del path `/auth/login`.
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ import { HardwareProfileComponent } from './components/hardware-profile/hardware
|
|||
import { HardwareCollectionModalComponent } from './components/hardware-profile/hardware-collection-modal/hardware-collection-modal.component';
|
||||
import { HardwareTypeComponent } from './components/hardware-type/hardware-type.component';
|
||||
import { ShowDetailsComponent } from './components/hardware-type/show-details/show-details.component';
|
||||
import { DiskPartitionSelectorComponent } from './components/groups/shared/disk-partition-selector/disk-partition-selector.component';
|
||||
export function HttpLoaderFactory(http: HttpClient) {
|
||||
return new TranslateHttpLoader(http, './locale/', '.json');
|
||||
}
|
||||
|
|
@ -366,7 +367,7 @@ registerLocaleData(localeEs, 'es-ES');
|
|||
progressAnimation: 'increasing',
|
||||
closeButton: true
|
||||
}
|
||||
), MatGridList, MatTree, MatTreeNode, MatNestedTreeNode, MatTreeNodeToggle, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeOutlet, MatPaginator, MatGridTile, MatExpansionPanel, MatExpansionPanelTitle, MatExpansionPanelDescription, MatRadioGroup, MatRadioButton, MatAutocompleteTrigger, NgOptimizedImage
|
||||
), MatGridList, MatTree, MatTreeNode, MatNestedTreeNode, MatTreeNodeToggle, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeOutlet, MatPaginator, MatGridTile, MatExpansionPanel, MatExpansionPanelTitle, MatExpansionPanelDescription, MatRadioGroup, MatRadioButton, MatAutocompleteTrigger, NgOptimizedImage, DiskPartitionSelectorComponent
|
||||
],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
{ translationKey: 'executeCommands.softwareInventory', slug: 'software-inventory', disabled: false },
|
||||
{ translationKey: 'executeCommands.hardwareInventory', slug: 'hardware-inventory', disabled: false },
|
||||
{ translationKey: 'executeCommands.runScript', slug: 'run-script', disabled: false },
|
||||
{ translationKey: 'executeCommands.refresh', slug: 'refresh', disabled: false },
|
||||
];
|
||||
|
||||
client: any = {};
|
||||
|
|
@ -79,7 +80,8 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
'software-inventory',
|
||||
'hardware-inventory',
|
||||
'remove-cache-image',
|
||||
'partition'
|
||||
'partition',
|
||||
'refresh'
|
||||
],
|
||||
'ou-minimal': [
|
||||
'power-on',
|
||||
|
|
@ -112,7 +114,7 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
if (states[0] === 'off' || states[0] === 'disconnected') {
|
||||
command.disabled = !['power-on', 'create-image'].includes(command.slug);
|
||||
} else {
|
||||
command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory', 'hardware-inventory'].includes(command.slug);
|
||||
command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory', 'hardware-inventory', 'refresh'].includes(command.slug);
|
||||
}
|
||||
} else {
|
||||
if (command.slug === 'software-inventory') {
|
||||
|
|
@ -125,6 +127,11 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
command.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.slug === 'refresh') {
|
||||
command.disabled = !states.every(state => state === 'og-live');
|
||||
}
|
||||
|
||||
return command;
|
||||
});
|
||||
}
|
||||
|
|
@ -173,6 +180,10 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
if (action === 'software-inventory') {
|
||||
this.softwareInventory();
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
this.refreshClient();
|
||||
}
|
||||
}
|
||||
|
||||
rebootClient(): void {
|
||||
|
|
@ -188,6 +199,19 @@ export class ExecuteCommandComponent implements OnInit {
|
|||
);
|
||||
}
|
||||
|
||||
refreshClient(): void {
|
||||
this.http.post(`${this.baseUrl}/clients/server/refresh`, {
|
||||
clients: this.clientData.map((client: any) => client['@id'])
|
||||
}).subscribe(
|
||||
response => {
|
||||
this.toastService.success('Petición de refresco enviada correctamente');
|
||||
},
|
||||
error => {
|
||||
this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
loginClient(): void {
|
||||
const clientDataToSend = this.clientData.map(client => ({
|
||||
name: client.name,
|
||||
|
|
|
|||
|
|
@ -206,39 +206,13 @@
|
|||
</div>
|
||||
|
||||
<div class="partition-table-container">
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-radio-group
|
||||
[(ngModel)]="selectedPartition"
|
||||
[disabled]="!row.operativeSystem"
|
||||
>
|
||||
<mat-radio-button [value]="row">
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
|
||||
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
|
||||
<td mat-cell *matCellDef="let image">
|
||||
<ng-container *ngIf="column.columnDef !== 'size'">
|
||||
{{ column.cell(image) }}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="column.columnDef === 'size'">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<span> {{ image.size }} MB</span>
|
||||
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
<app-disk-partition-selector
|
||||
[(selectedPartition)]="selectedPartition"
|
||||
[partitions]="dataSource.data"
|
||||
[columns]="columns"
|
||||
[displayedColumns]="displayedColumns"
|
||||
[disableCondition]="isPartitionDisabled">
|
||||
</app-disk-partition-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,12 +62,11 @@ export class CreateClientImageComponent implements OnInit{
|
|||
return this.gitData && this.gitData.repo && this.gitData.branch;
|
||||
}
|
||||
|
||||
isPartitionDisabled = (row: any): boolean => {
|
||||
return !row.operativeSystem;
|
||||
};
|
||||
|
||||
columns = [
|
||||
{
|
||||
columnDef: 'diskNumber',
|
||||
header: 'Disco',
|
||||
cell: (partition: any) => partition.diskNumber
|
||||
},
|
||||
{
|
||||
columnDef: 'partitionNumber',
|
||||
header: 'Particion',
|
||||
|
|
|
|||
|
|
@ -628,6 +628,11 @@ table {
|
|||
}
|
||||
}
|
||||
|
||||
.partition-table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.commits-table-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,17 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="action-button" id="execute-button"
|
||||
[disabled]="!isFormValid()"
|
||||
(click)="save()">Ejecutar</button>
|
||||
<button class="action-button secondary-button" (click)="generateOgInstructions()">
|
||||
<button class="action-button" id="execute-button" [disabled]="!isFormValid()" (click)="save()">Ejecutar</button>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="action-button" (click)="generateOgInstructions()">
|
||||
Generar instrucciones
|
||||
</button>
|
||||
<button class="action-button" color="accent"
|
||||
[disabled]="!isFormValid()"
|
||||
(click)="openScheduleModal()">
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="action-button" color="accent" [disabled]="!isFormValid()" (click)="openScheduleModal()">
|
||||
Opciones de programación
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -43,7 +45,8 @@
|
|||
<div class="section-hint" (click)="$event.stopPropagation()">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>
|
||||
Puedes marcar o desmarcar un cliente haciendo clic en el área del ordenador. Además, selecciona con "Modelo" el equipo que actuará como modelo.
|
||||
Puedes marcar o desmarcar un cliente haciendo clic en el área del ordenador. Además, selecciona con "Modelo" el
|
||||
equipo que actuará como modelo.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -55,17 +58,11 @@
|
|||
|
||||
<div class="clients-grid">
|
||||
<div *ngFor="let client of clientData" class="client-item">
|
||||
<div class="client-card"
|
||||
(click)="toggleClientSelection(client)"
|
||||
[ngClass]="{'selected-client': client.selected}"
|
||||
[matTooltip]="getPartitionsTooltip(client)"
|
||||
matTooltipPosition="above"
|
||||
matTooltipClass="custom-tooltip">
|
||||
<div class="client-card" (click)="toggleClientSelection(client)"
|
||||
[ngClass]="{'selected-client': client.selected}" [matTooltip]="getPartitionsTooltip(client)"
|
||||
matTooltipPosition="above" matTooltipClass="custom-tooltip">
|
||||
|
||||
<img
|
||||
[src]="'assets/images/computer_' + client.status + '.svg'"
|
||||
alt="Client Icon"
|
||||
class="client-image" />
|
||||
<img [src]="'assets/images/computer_' + client.status + '.svg'" alt="Client Icon" class="client-image" />
|
||||
|
||||
<div class="client-details">
|
||||
<span class="client-name">{{ client.name | slice:0:20 }}</span>
|
||||
|
|
@ -76,9 +73,7 @@
|
|||
|
||||
<div class="model-selector" (click)="$event.stopPropagation()">
|
||||
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="onModelSelected(selectedModelClient)">
|
||||
<mat-radio-button [value]="client"
|
||||
color="primary"
|
||||
(click)="$event.stopPropagation()">
|
||||
<mat-radio-button [value]="client" color="primary" (click)="$event.stopPropagation()">
|
||||
Modelo
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
|
@ -89,6 +84,8 @@
|
|||
</mat-expansion-panel>
|
||||
</div>
|
||||
|
||||
<mat-divider style="margin-top: 20px;"></mat-divider>
|
||||
|
||||
<div class="select-container config-container">
|
||||
<div class="deploy-config-row">
|
||||
<!-- Sección: Configuración de imagen -->
|
||||
|
|
@ -97,19 +94,21 @@
|
|||
<mat-icon>image</mat-icon>
|
||||
Configuración de imagen
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width">
|
||||
<mat-label>Tipo de imagen</mat-label>
|
||||
<mat-select [(ngModel)]="imageType" (selectionChange)="onImageTypeSelected($event.value)">
|
||||
<mat-option [value]="'monolithic'">Monolítica</mat-option>
|
||||
<mat-option [value]="'git'">Git</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="selector">
|
||||
<mat-form-field appearance="fill" class="half-width">
|
||||
<mat-label>Tipo de imagen</mat-label>
|
||||
<mat-select [(ngModel)]="imageType" (selectionChange)="onImageTypeSelected($event.value)">
|
||||
<mat-option [value]="'monolithic'">Monolítica</mat-option>
|
||||
<mat-option [value]="'git'">Git</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Campos monolítica -->
|
||||
<div *ngIf="imageType === 'monolithic'">
|
||||
<mat-form-field appearance="fill" class="full-width">
|
||||
<mat-label>Método de deploy</mat-label>
|
||||
<div class="monolithic-row" *ngIf="imageType === 'monolithic'">
|
||||
<mat-form-field appearance="fill" class="half-width">
|
||||
<mat-label>Seleccione método de deploy</mat-label>
|
||||
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
|
||||
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
|
||||
</mat-select>
|
||||
|
|
@ -118,7 +117,7 @@
|
|||
<mat-label>Seleccione imagen</mat-label>
|
||||
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled]="!imageType">
|
||||
<mat-option *ngFor="let image of images" [value]="image">
|
||||
<div class="unit-name">{{ image.name }}</div>
|
||||
<div class="unit-name"> {{ image.name }}</div>
|
||||
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
|
|
@ -126,10 +125,11 @@
|
|||
</div>
|
||||
|
||||
<!-- Campos Git -->
|
||||
<div *ngIf="imageType === 'git'">
|
||||
<mat-form-field appearance="fill" class="full-width">
|
||||
<mat-label>Repositorio</mat-label>
|
||||
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)" [disabled]="loadingRepositories">
|
||||
<div *ngIf="imageType === 'git'" class="filters-row git-gap">
|
||||
<mat-form-field appearance="fill" style="width: 300px;">
|
||||
<mat-label>Seleccionar Repositorio</mat-label>
|
||||
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)"
|
||||
[disabled]="loadingRepositories">
|
||||
<mat-option *ngFor="let repo of repositories" [value]="repo">
|
||||
{{ repo }}
|
||||
</mat-option>
|
||||
|
|
@ -137,9 +137,10 @@
|
|||
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width">
|
||||
<mat-label>Rama</mat-label>
|
||||
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()" [disabled]="loadingBranches || !selectedRepository">
|
||||
<mat-form-field appearance="fill" style="width: 300px;">
|
||||
<mat-label>Seleccionar Rama</mat-label>
|
||||
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()"
|
||||
[disabled]="loadingBranches || !selectedRepository">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch">
|
||||
{{ branch }}
|
||||
</mat-option>
|
||||
|
|
@ -147,8 +148,8 @@
|
|||
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="full-width">
|
||||
<mat-label>Filtrar por tag</mat-label>
|
||||
<mat-form-field appearance="fill" style="width: 200px;">
|
||||
<mat-label>Tag</mat-label>
|
||||
<mat-select [(ngModel)]="showOnlyTagged" (selectionChange)="onTagFilterChange()" [disabled]="!commits.length">
|
||||
<mat-option [value]="false">No</mat-option>
|
||||
<mat-option [value]="true">Sí</mat-option>
|
||||
|
|
@ -177,7 +178,7 @@
|
|||
<mat-icon>storage</mat-icon>
|
||||
Selección de partición
|
||||
</div>
|
||||
|
||||
|
||||
<div class="partition-table-container">
|
||||
<div *ngIf="showInstructions" class="instructions-box">
|
||||
<mat-card class="instructions-card">
|
||||
|
|
@ -193,27 +194,12 @@
|
|||
</mat-card>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="filteredPartitions">
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-radio-group [(ngModel)]="selectedPartition" name="selectedPartition">
|
||||
<mat-radio-button [value]="row">
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
|
||||
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
|
||||
<td mat-cell *matCellDef="let image">
|
||||
{{ column.cell(image) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
<app-disk-partition-selector
|
||||
[(selectedPartition)]="selectedPartition"
|
||||
[partitions]="filteredPartitions"
|
||||
[columns]="columns"
|
||||
[displayedColumns]="displayedColumns">
|
||||
</app-disk-partition-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,8 +241,8 @@
|
|||
<th mat-header-cell *matHeaderCellDef>Tags</th>
|
||||
<td mat-cell *matCellDef="let commit">
|
||||
<mat-chip-list>
|
||||
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected class="custom-tag-chip"
|
||||
style="background: rgba(102, 126, 234, 0.1) !important; color: #667eea !important; border: 1px solid rgba(102, 126, 234, 0.3) !important; border-radius: 8px !important; font-weight: 500 !important; font-size: 10px !important; padding: 2px 6px !important; margin: 1px !important; box-shadow: none !important;">
|
||||
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected class="custom-tag-chip"
|
||||
style="background: rgba(102, 126, 234, 0.1) !important; color: #667eea !important; border: 1px solid rgba(102, 126, 234, 0.3) !important; border-radius: 8px !important; font-weight: 500 !important; font-size: 10px !important; padding: 2px 6px !important; margin: 1px !important; box-shadow: none !important;">
|
||||
{{ tag }}
|
||||
</mat-chip>
|
||||
<span *ngIf="!commit.tags || commit.tags.length === 0" class="no-tags-text">Sin tags</span>
|
||||
|
|
@ -268,16 +254,8 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<app-scroll-to-top
|
||||
[threshold]="100"
|
||||
targetElement=".header-container"
|
||||
position="bottom-right"
|
||||
[showTooltip]="true"
|
||||
tooltipText="Volver arriba"
|
||||
tooltipPosition="left">
|
||||
<app-scroll-to-top [threshold]="100" targetElement=".header-container" position="bottom-right" [showTooltip]="true"
|
||||
tooltipText="Volver arriba" tooltipPosition="left">
|
||||
</app-scroll-to-top>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export class DeployImageComponent implements OnInit{
|
|||
selectedClients: any[] = [];
|
||||
selectedModelClient: any = null;
|
||||
filteredPartitions: any[] = [];
|
||||
|
||||
selectedRepository: any = null;
|
||||
imageType: string = 'monolithic';
|
||||
|
||||
|
|
@ -71,14 +72,9 @@ export class DeployImageComponent implements OnInit{
|
|||
|
||||
dataSource = new MatTableDataSource<any>();
|
||||
columns = [
|
||||
{
|
||||
columnDef: 'diskNumber',
|
||||
header: 'Disco',
|
||||
cell: (partition: any) => `${partition.diskNumber}`
|
||||
},
|
||||
{
|
||||
columnDef: 'partitionNumber',
|
||||
header: 'Particion',
|
||||
header: 'Partición',
|
||||
cell: (partition: any) => `${partition.partitionNumber}`
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export interface Client {
|
|||
createdBy: string;
|
||||
uuid: string;
|
||||
parentName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ClientCollection {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,21 @@ table {
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
.notes-card {
|
||||
background-color: #f1f1f1;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.notes-value {
|
||||
font-size: 1rem;
|
||||
color: #222;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -130,3 +145,120 @@ table {
|
|||
gap: 1em;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ngx-charts custom wrapped legend */
|
||||
::ng-deep .charts-container .legend-labels {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
::ng-deep .charts-container .legend-label {
|
||||
width: auto !important;
|
||||
margin-right: 16px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
::ng-deep .charts-container .legend-label-text {
|
||||
max-width: none !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
::ng-deep .charts-container .legend-wrap {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Extracted Card Redesign Classes */
|
||||
.disk-card-wrapper {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.disk-mat-card {
|
||||
border-radius: 8px;
|
||||
overflow: visible !important;
|
||||
padding: 0;
|
||||
}
|
||||
.disk-card-header {
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.disk-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #3f51b5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.disk-card-metrics-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.disk-card-metrics-chip {
|
||||
background-color: white !important;
|
||||
border: 1px solid #ccc;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.disk-card-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: white;
|
||||
gap: 0;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.disk-table-container {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.disk-table {
|
||||
width: 100%;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
.disk-chart-container {
|
||||
flex: 0 0 400px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 30px 20px 50px 20px;
|
||||
box-sizing: border-box;
|
||||
background: #fafafa;
|
||||
min-height: 350px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.disk-chart-wrapper {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* OS Column Classes */
|
||||
.os-col-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cache-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
.cache-item {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
.image-name {
|
||||
font-size: 0.75rem;
|
||||
color: gray;
|
||||
}
|
||||
.image-date {
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<mat-dialog-content class="modal-content">
|
||||
<app-loading [isLoading]="loading"></app-loading>
|
||||
|
||||
<div class="client-container" *ngIf="!loading">
|
||||
@if (!loading) {
|
||||
<div class="client-container">
|
||||
<div class="header-container">
|
||||
<h2 class="title">{{ 'clientDetailsTitle' | translate }} {{ clientData.name }}</h2>
|
||||
</div>
|
||||
|
|
@ -9,75 +10,120 @@
|
|||
<div class="client-info-card">
|
||||
<div class="info-columns">
|
||||
<div class="info-column">
|
||||
<div class="info-pair" *ngFor="let data of generalData">
|
||||
@for (data of generalData; track data) {
|
||||
<div class="info-pair">
|
||||
<div class="label">{{ data?.property }}</div>
|
||||
<div class="value">{{ data?.value || '--' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="info-column">
|
||||
<div class="info-pair" *ngFor="let data of networkData">
|
||||
@for (data of networkData; track data) {
|
||||
<div class="info-pair">
|
||||
<div class="label">{{ data?.property }}</div>
|
||||
<div class="value">{{ data?.value || '--' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notes-card">
|
||||
<div class="label">{{ 'notesLabel' | translate }}</div>
|
||||
<div class="notes-value">{{ clientNotes || '--' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-header-container">
|
||||
<h2 i18n="@@adminImagesTitle">Discos/Particiones</h2>
|
||||
<mat-chip *ngIf="clientData.firmwareType"> {{ clientData.firmwareType }}</mat-chip>
|
||||
@if (clientData.firmwareType) {
|
||||
<mat-chip> {{ clientData.firmwareType }}</mat-chip>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="disk-container">
|
||||
<div class="disk-layout">
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
|
||||
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
|
||||
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
|
||||
<td mat-cell *matCellDef="let image">
|
||||
<ng-container *ngIf="column.columnDef !== 'size' && column.columnDef !== 'operativeSystem'">
|
||||
{{ column.cell(image) }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="column.columnDef === 'size'">
|
||||
<mat-chip color="primary">
|
||||
{{ (image.size / 1024).toFixed(2) }} GB
|
||||
</mat-chip>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="column.columnDef === 'operativeSystem'">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<span>{{image.operativeSystem?.name }}</span>
|
||||
<span style="font-size: 0.75rem; color: gray;">{{ image.image?.name }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
|
||||
<ng-container>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;" [hidden]="row.partitionNumber === 0"></tr>
|
||||
</ng-container>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="charts-container" [ngClass]="{'single-disk': chartDisk.length === 1}">
|
||||
<ng-container *ngIf="diskUsageData && diskUsageData.length > 0">
|
||||
<div *ngFor="let disk of chartDisk" class="disk-usage">
|
||||
<ngx-charts-pie-chart class="chart" [view]="view" [results]="disk.chartData" [doughnut]="true">
|
||||
</ngx-charts-pie-chart>
|
||||
<h3>Disco {{ disk.diskNumber }}</h3>
|
||||
<p>Usado: {{ (disk.used).toFixed(2) }} GB ({{ disk.percentage }}%)</p>
|
||||
<p>Total: {{ disk.total }} GB</p>
|
||||
@for (disk of diskUsageData; track disk.diskNumber) {
|
||||
<div class="disk-card-wrapper">
|
||||
<mat-card class="disk-mat-card mat-elevation-z4">
|
||||
<div class="disk-card-header">
|
||||
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}</h3>
|
||||
<div class="disk-card-metrics-wrapper">
|
||||
<mat-chip class="disk-card-metrics-chip">
|
||||
Total: {{ disk.total.toFixed(2) }} GB | Usado: {{ (disk.used).toFixed(2) }} GB ({{ disk.percentage }}%)
|
||||
</mat-chip>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="disk-card-layout">
|
||||
<div class="disk-table-container">
|
||||
<table mat-table [dataSource]="disk.partitions" class="disk-table">
|
||||
@for (column of columns; track column.columnDef) {
|
||||
<ng-container [matColumnDef]="column.columnDef">
|
||||
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
|
||||
<td mat-cell *matCellDef="let image">
|
||||
@if (column.columnDef !== 'size' && column.columnDef !== 'operativeSystem') {
|
||||
{{ column.cell(image) }}
|
||||
}
|
||||
@if (column.columnDef === 'size') {
|
||||
<mat-chip color="primary">
|
||||
{{ (image.size / 1024).toFixed(2) }} GB
|
||||
</mat-chip>
|
||||
}
|
||||
|
||||
@if (column.columnDef === 'operativeSystem') {
|
||||
<div class="os-col-wrapper">
|
||||
@if (image.filesystem === 'CACHE' && image.cacheContent) {
|
||||
<span class="cache-title">Contenido:</span>
|
||||
@for (item of parseCacheContent(image.cacheContent); track item.name) {
|
||||
<div class="cache-item">
|
||||
• {{ item.name }} ({{ item.size }})
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (image.filesystem !== 'CACHE' || !image.cacheContent) {
|
||||
<span>{{image.operativeSystem?.name }}</span>
|
||||
<span class="image-name">{{ image.image?.name }}</span>
|
||||
@if (image.deployDate) {
|
||||
<span class="image-date">Instalada el {{ image.deployDate | date:'dd/MM/yyyy HH:mm' }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
|
||||
<ng-container>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;" [hidden]="row.partitionNumber === 0"></tr>
|
||||
</ng-container>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="charts-container disk-chart-container">
|
||||
<div class="disk-chart-wrapper">
|
||||
<ngx-charts-pie-chart
|
||||
class="chart"
|
||||
[view]="[360, 230]"
|
||||
[results]="disk.chartData"
|
||||
[doughnut]="true"
|
||||
[legend]="true"
|
||||
[legendPosition]="legendPosition"
|
||||
[legendTitle]="''">
|
||||
</ngx-charts-pie-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions class="action-container">
|
||||
<button class="ordinary-button" (click)="onNoClick()">{{ 'closeButton' | translate }}</button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -37,18 +37,14 @@ export class ClientDetailsComponent {
|
|||
chartDisk: any[] = [];
|
||||
view: [number, number] = [260, 160];
|
||||
showLegend: boolean = true;
|
||||
legendPosition: any = 'below';
|
||||
|
||||
datePipe: DatePipe = new DatePipe('es-ES');
|
||||
columns = [
|
||||
{
|
||||
columnDef: 'diskNumber',
|
||||
header: 'Disco',
|
||||
cell: (partition: any) => partition.diskNumber,
|
||||
},
|
||||
{
|
||||
columnDef: 'partitionNumber',
|
||||
header: 'Particion',
|
||||
cell: (partition: any) => partition.partitionNumber
|
||||
columnDef: 'partition',
|
||||
header: 'Partición',
|
||||
cell: (partition: any) => `${partition.partitionNumber}`
|
||||
},
|
||||
{
|
||||
columnDef: 'partitionCode',
|
||||
|
|
@ -129,6 +125,10 @@ export class ClientDetailsComponent {
|
|||
];
|
||||
}
|
||||
|
||||
get clientNotes(): string {
|
||||
return this.clientData?.notes || '';
|
||||
}
|
||||
|
||||
calculateDiskUsage() {
|
||||
const diskUsageMap = new Map<number, { total: number, used: number, partitions: any[] }>();
|
||||
|
||||
|
|
@ -168,7 +168,8 @@ export class ClientDetailsComponent {
|
|||
chartData: partitionData,
|
||||
total,
|
||||
used,
|
||||
percentage: total > 0 ? Math.round((used / total) * 100) : 0
|
||||
percentage: total > 0 ? Math.round((used / total) * 100) : 0,
|
||||
partitions
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -194,6 +195,15 @@ export class ClientDetailsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
parseCacheContent(content: string): { name: string, size: string }[] {
|
||||
if (!content) return [];
|
||||
return content.split(',').map(item => {
|
||||
const [name, bytes] = item.split(':');
|
||||
const sizeMB = (parseInt(bytes) / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
return { name, size: sizeMB };
|
||||
});
|
||||
}
|
||||
|
||||
onNoClick(): void {
|
||||
this.dialog.closeAll();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ mat-option .unit-path {
|
|||
row-gap: 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@
|
|||
<mat-checkbox formControlName="maintenance">
|
||||
{{ 'maintenance' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
<mat-form-field class="form-field full-width">
|
||||
<mat-label>{{ 'notesLabel' | translate }}</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="4"
|
||||
[placeholder]="'notesPlaceholder' | translate"></textarea>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ export class ManageClientComponent implements OnInit {
|
|||
repository: [null],
|
||||
menu: [null],
|
||||
maintenance: [false],
|
||||
resolution: [null]
|
||||
resolution: [null],
|
||||
notes: ['']
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +251,8 @@ export class ManageClientComponent implements OnInit {
|
|||
pxeTemplate: data.pxeTemplate ? data.pxeTemplate['@id'] : null,
|
||||
menu: data.menu ? data.menu['@id'] : null,
|
||||
maintenance: data.maintenance,
|
||||
resolution: data.resolution
|
||||
resolution: data.resolution,
|
||||
notes: data.notes || ''
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
/* Formatting specific to grouped partition selector */
|
||||
.disk-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.disk-card-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.disk-mat-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.disk-card-header {
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.disk-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #3f51b5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.disk-card-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: white;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.disk-table-container {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.disk-table {
|
||||
width: 100%;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<div class="disk-container">
|
||||
@for (disk of diskUsageData; track disk.diskNumber) {
|
||||
<div class="disk-card-wrapper">
|
||||
<mat-card class="disk-mat-card mat-elevation-z4">
|
||||
<div class="disk-card-header">
|
||||
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="disk-card-layout">
|
||||
<div class="disk-table-container">
|
||||
<table mat-table [dataSource]="disk.partitions" class="disk-table">
|
||||
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-radio-group
|
||||
[ngModel]="selectedPartition"
|
||||
(ngModelChange)="onPartitionSelected($event)"
|
||||
name="selectedPartitionGroup"
|
||||
[disabled]="disableCondition(row)">
|
||||
<mat-radio-button [value]="row"></mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@for (column of columns; track column.columnDef) {
|
||||
<ng-container [matColumnDef]="column.columnDef">
|
||||
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
|
||||
<td mat-cell *matCellDef="let partition">
|
||||
@if (column.columnDef === 'size') {
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<span>{{ partition.size }} MB</span>
|
||||
<span style="font-size: 0.75rem; color: gray;">{{ partition.size / 1024 | number:'1.0-2' }} GB</span>
|
||||
</div>
|
||||
} @else {
|
||||
{{ column.cell ? column.cell(partition) : partition[column.columnDef] }}
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"
|
||||
(click)="onPartitionSelected(row)"
|
||||
[ngStyle]="{'cursor': disableCondition(row) ? 'default' : 'pointer', 'opacity': disableCondition(row) ? '0.5' : '1'}">
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-disk-partition-selector',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatTableModule, MatRadioModule, FormsModule],
|
||||
templateUrl: './disk-partition-selector.component.html',
|
||||
styleUrl: './disk-partition-selector.component.css'
|
||||
})
|
||||
export class DiskPartitionSelectorComponent implements OnChanges {
|
||||
@Input() partitions: any[] = [];
|
||||
@Input() columns: any[] = [];
|
||||
@Input() displayedColumns: string[] = [];
|
||||
@Input() selectedPartition: any;
|
||||
@Input() disableCondition: (row: any) => boolean = () => false;
|
||||
|
||||
@Output() selectedPartitionChange = new EventEmitter<any>();
|
||||
|
||||
diskUsageData: any[] = [];
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['partitions']) {
|
||||
this.calculateDiskUsage(this.partitions || []);
|
||||
}
|
||||
}
|
||||
|
||||
calculateDiskUsage(partitions: any[]) {
|
||||
this.diskUsageData = [];
|
||||
const grouped = partitions.reduce((acc: any, curr: any) => {
|
||||
if (!acc[curr.diskNumber]) {
|
||||
acc[curr.diskNumber] = [];
|
||||
}
|
||||
acc[curr.diskNumber].push(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const diskNum in grouped) {
|
||||
if (grouped.hasOwnProperty(diskNum)) {
|
||||
this.diskUsageData.push({
|
||||
diskNumber: diskNum,
|
||||
partitions: grouped[diskNum]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPartitionSelected(row: any) {
|
||||
if (!this.disableCondition(row)) {
|
||||
this.selectedPartition = row;
|
||||
this.selectedPartitionChange.emit(this.selectedPartition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,5 +72,10 @@ export const COMMAND_TYPES: any = {
|
|||
'kill-job': {
|
||||
en: 'Cancel Task',
|
||||
es: 'Cancelar Tarea'
|
||||
},
|
||||
|
||||
refresh: {
|
||||
en: 'Refresh',
|
||||
es: 'Refrescar'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -589,7 +589,8 @@
|
|||
"partition": "Partition and Format",
|
||||
"softwareInventory": "Software Inventory",
|
||||
"hardwareInventory": "Hardware Inventory",
|
||||
"runScript": "Run Script"
|
||||
"runScript": "Run Script",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"changeOU": "Change Organizational Unit",
|
||||
"moveClientsTooltip": "Move clients to another organizational unit",
|
||||
|
|
|
|||
|
|
@ -590,7 +590,8 @@
|
|||
"partition": "Particionar y Formatear",
|
||||
"softwareInventory": "Inventario Software",
|
||||
"hardwareInventory": "Inventario Hardware",
|
||||
"runScript": "Ejecutar script"
|
||||
"runScript": "Ejecutar script",
|
||||
"refresh": "Refrescar"
|
||||
},
|
||||
"changeOU": "Mover",
|
||||
"moveClientsTooltip": "Mover clientes a otra unidad organizativa",
|
||||
|
|
|
|||
Loading…
Reference in New Issue