Implement data refresh functionality for organizational units and clients
testing/ogGui-multibranch/pipeline/head This commit looks good
Details
testing/ogGui-multibranch/pipeline/head This commit looks good
Details
parent
6f226d6da6
commit
46dd71889d
|
@ -534,7 +534,7 @@ button[mat-raised-button] {
|
|||
.clients-title-name {
|
||||
font-size: x-large;
|
||||
display: block;
|
||||
padding: 1rem 1rem 1rem 16px;
|
||||
padding: 1rem 1rem 1rem 13px;
|
||||
}
|
||||
|
||||
.no-clients-info {
|
||||
|
|
|
@ -278,7 +278,7 @@
|
|||
<mat-icon>visibility</mat-icon>
|
||||
<span>{{ 'viewDetails' | translate }}</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteClick($event, client, selectedNode)">
|
||||
<button mat-menu-item (click)="onDeleteClick($event, client)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>{{ 'delete' | translate }}</span>
|
||||
</button>
|
||||
|
|
|
@ -3,7 +3,6 @@ import { HttpClient } from '@angular/common/http';
|
|||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatBottomSheet } from '@angular/material/bottom-sheet';
|
||||
import { MatTabChangeEvent } from '@angular/material/tabs';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { JoyrideService } from 'ngx-joyride';
|
||||
import { FlatTreeControl } from '@angular/cdk/tree';
|
||||
|
@ -114,7 +113,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
this.search();
|
||||
this.getFilters();
|
||||
this.updateGridCols();
|
||||
this.loadOrganizationalUnits();
|
||||
this.refreshData();
|
||||
window.addEventListener('resize', this.updateGridCols);
|
||||
|
||||
this.selectedClients.filterPredicate = (client: Client, filter: string): boolean => {
|
||||
|
@ -143,41 +142,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
ip: node.ip,
|
||||
'@id': node['@id'],
|
||||
});
|
||||
|
||||
private loadOrganizationalUnits(): void {
|
||||
this.loading = true;
|
||||
this.isLoadingClients = true;
|
||||
this.dataService.getOrganizationalUnits().subscribe(
|
||||
(data) => {
|
||||
this.organizationalUnits = data;
|
||||
this.loading = false;
|
||||
|
||||
if (this.organizationalUnits.length > 0) {
|
||||
const treeData = this.organizationalUnits.map((unidad) => this.convertToTreeData(unidad));
|
||||
this.treeDataSource.data = treeData.flat();
|
||||
|
||||
this.isTreeViewActive = true;
|
||||
|
||||
const firstNode = this.treeDataSource.data[0];
|
||||
if (firstNode) {
|
||||
this.selectedNode = firstNode;
|
||||
this.fetchClientsForNode(firstNode);
|
||||
}
|
||||
} else {
|
||||
this.toastr.info('No existen unidades organizativas');
|
||||
this.isTreeViewActive = false;
|
||||
this.isLoadingClients = false;
|
||||
return;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error fetching organizational units', error);
|
||||
this.loading = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
toggleView(view: 'card' | 'list'): void {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
@ -239,49 +204,111 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
private async loadChildrenAndClients(id: string): Promise<UnidadOrganizativa> {
|
||||
try {
|
||||
const childrenData = await this.dataService.getChildren(id).toPromise();
|
||||
|
||||
const processHierarchy = (nodes: UnidadOrganizativa[]): UnidadOrganizativa[] => {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? processHierarchy(node.children) : [],
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
...this.selectedUnidad!,
|
||||
children: childrenData ? processHierarchy(childrenData) : [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading children:', error);
|
||||
return this.selectedUnidad!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private convertToTreeData(data: UnidadOrganizativa): TreeNode[] {
|
||||
private convertToTreeData(data: UnidadOrganizativa): TreeNode {
|
||||
const processNode = (node: UnidadOrganizativa): TreeNode => ({
|
||||
id: node.id,
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
'@id': node['@id'],
|
||||
children: node.children?.map(processNode) || [],
|
||||
hasClients: (node.clients?.length ?? 0) > 0,
|
||||
});
|
||||
return [processNode(data)];
|
||||
return processNode(data);
|
||||
}
|
||||
|
||||
private refreshData(selectedNodeIdOrUuid?: string): void {
|
||||
this.loading = true;
|
||||
this.isLoadingClients = !!selectedNodeIdOrUuid;
|
||||
|
||||
this.dataService.getOrganizationalUnits().subscribe({
|
||||
next: (data) => {
|
||||
this.treeDataSource.data = data.map((unidad) => this.convertToTreeData(unidad));
|
||||
|
||||
if (selectedNodeIdOrUuid) {
|
||||
this.selectedNode = this.findNodeByIdOrUuid(this.treeDataSource.data, selectedNodeIdOrUuid);
|
||||
|
||||
if (this.selectedNode) {
|
||||
this.treeControl.collapseAll();
|
||||
this.expandPathToNode(this.selectedNode);
|
||||
this.fetchClientsForNode(this.selectedNode);
|
||||
}
|
||||
} else {
|
||||
this.treeControl.collapseAll();
|
||||
if (this.treeDataSource.data.length > 0) {
|
||||
this.selectedNode = this.treeDataSource.data[0];
|
||||
this.fetchClientsForNode(this.selectedNode);
|
||||
} else {
|
||||
this.selectedNode = null;
|
||||
this.selectedClients.data = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.isLoadingClients = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error fetching organizational units', error);
|
||||
this.toastr.error('Ocurrió un error al cargar las unidades organizativas');
|
||||
this.loading = false;
|
||||
this.isLoadingClients = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private expandPathToNode(node: TreeNode): void {
|
||||
const path: TreeNode[] = [];
|
||||
let currentNode: TreeNode | null = node;
|
||||
|
||||
while (currentNode) {
|
||||
path.unshift(currentNode);
|
||||
currentNode = currentNode.id ? this.findParentNode(this.treeDataSource.data, currentNode.id) : null;
|
||||
}
|
||||
|
||||
path.forEach((pathNode) => {
|
||||
const flatNode = this.treeControl.dataNodes?.find((n) => n.id === pathNode.id);
|
||||
if (flatNode) {
|
||||
this.treeControl.expand(flatNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private findParentNode(treeData: TreeNode[], childId: string): TreeNode | null {
|
||||
for (const node of treeData) {
|
||||
if (node.children?.some((child) => child.id === childId)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const parent = this.findParentNode(node.children, childId);
|
||||
if (parent) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private findNodeByIdOrUuid(treeData: TreeNode[], identifier: string): TreeNode | null {
|
||||
const search = (nodes: TreeNode[]): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === identifier || node.uuid === identifier) return node;
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = search(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return search(treeData);
|
||||
}
|
||||
|
||||
onNodeClick(node: TreeNode): void {
|
||||
console.log('Node clicked:', node);
|
||||
this.selectedNode = node;
|
||||
this.fetchClientsForNode(node);
|
||||
}
|
||||
|
||||
private fetchClientsForNode(node: TreeNode): void {
|
||||
console.log('Node:', node);
|
||||
this.isLoadingClients = true;
|
||||
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}`).subscribe({
|
||||
next: (response) => {
|
||||
|
@ -317,87 +344,76 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
data: { parent },
|
||||
width: '900px',
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.refreshOrganizationalUnits();
|
||||
dialogRef.afterClosed().subscribe((newUnit) => {
|
||||
if (newUnit?.uuid) {
|
||||
console.log('Unidad organizativa creada:', newUnit);
|
||||
this.refreshData(newUnit.uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
|
||||
event.stopPropagation();
|
||||
const targetNode = organizationalUnit || this.selectedNode;
|
||||
const dialogRef = this.dialog.open(CreateClientComponent, {
|
||||
data: { organizationalUnit },
|
||||
data: { organizationalUnit: targetNode },
|
||||
width: '900px',
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.refreshOrganizationalUnits();
|
||||
if (organizationalUnit && organizationalUnit['@id']) {
|
||||
this.refreshClientsForNode(organizationalUnit);
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result?.client && result?.organizationalUnit) {
|
||||
const organizationalUnitUrl = result.organizationalUnit;
|
||||
const uuid = organizationalUnitUrl.split('/')[2];
|
||||
const parentNode = this.findNodeByIdOrUuid(this.treeDataSource.data, uuid);
|
||||
|
||||
if (parentNode) {
|
||||
this.refreshData(parentNode.uuid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
|
||||
event.stopPropagation();
|
||||
const targetNode = organizationalUnit || this.selectedNode;
|
||||
|
||||
const dialogRef = this.dialog.open(CreateMultipleClientComponent, {
|
||||
data: { organizationalUnit },
|
||||
data: { organizationalUnit: targetNode },
|
||||
width: '900px',
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.refreshOrganizationalUnits();
|
||||
if (organizationalUnit && organizationalUnit['@id']) {
|
||||
this.refreshClientsForNode(organizationalUnit);
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result?.success) {
|
||||
const organizationalUnitUrl = result.organizationalUnit;
|
||||
const uuid = organizationalUnitUrl.split('/')[2];
|
||||
const parentNode = this.findNodeByIdOrUuid(this.treeDataSource.data, uuid);
|
||||
|
||||
if (parentNode) {
|
||||
console.log('Nodo padre encontrado para actualización:', parentNode);
|
||||
this.refreshData(parentNode.uuid);
|
||||
} else {
|
||||
console.error('No se encontró el nodo padre después de la creación masiva.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private refreshOrganizationalUnits(): void {
|
||||
const expandedNodeIds = this.treeControl.dataNodes
|
||||
? this.treeControl.dataNodes
|
||||
.filter(node => this.treeControl.isExpanded(node))
|
||||
.map(node => this.extractUuid(node['@id']))
|
||||
: [];
|
||||
|
||||
this.subscriptions.add(
|
||||
this.dataService.getOrganizationalUnits().subscribe(
|
||||
(data) => {
|
||||
this.organizationalUnits = data;
|
||||
if (this.selectedUnidad) {
|
||||
this.loadChildrenAndClients(this.selectedUnidad?.id || '').then((updatedData) => {
|
||||
this.selectedUnidad = updatedData;
|
||||
const treeData = this.convertToTreeData(updatedData);
|
||||
this.originalTreeData = treeData[0]?.children || [];
|
||||
this.treeDataSource.data = [...this.originalTreeData];
|
||||
|
||||
setTimeout(() => {
|
||||
this.treeControl.dataNodes.forEach(node => {
|
||||
const nodeId = this.extractUuid(node['@id']);
|
||||
if (nodeId && expandedNodeIds.includes(nodeId)) {
|
||||
this.treeControl.expand(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
(error) => console.error('Error fetching organizational units', error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
onEditNode(event: MouseEvent, node: TreeNode | null): void {
|
||||
event.stopPropagation();
|
||||
const uuid = node ? this.extractUuid(node['@id']) : null;
|
||||
if (!uuid) return;
|
||||
|
||||
if (node && node.type !== NodeType.Client) {
|
||||
this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' });
|
||||
} else {
|
||||
this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
|
||||
}
|
||||
const dialogRef = node?.type !== NodeType.Client
|
||||
? this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
|
||||
: this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
|
||||
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
if (node) {
|
||||
this.refreshData(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteClick(event: MouseEvent, node: TreeNode | null, clientNode?: TreeNode | null): void {
|
||||
onDeleteClick(event: MouseEvent, node: TreeNode | null): void {
|
||||
event.stopPropagation();
|
||||
const uuid = node ? this.extractUuid(node['@id']) : null;
|
||||
if (!uuid) return;
|
||||
|
@ -410,44 +426,50 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result === true) {
|
||||
this.deleteEntity(uuid, node.type, node);
|
||||
this.deleteEntityorClient(uuid, node?.type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private deleteEntity(uuid: string, type: string, node: TreeNode): void {
|
||||
this.subscriptions.add(
|
||||
this.dataService.deleteElement(uuid, type).subscribe(
|
||||
() => {
|
||||
this.refreshOrganizationalUnits();
|
||||
if (type === NodeType.Client) {
|
||||
this.refreshClientsForNode(node);
|
||||
}
|
||||
this.toastr.success('Entidad eliminada exitosamente');
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error deleting entity:', error);
|
||||
this.toastr.error('Error al eliminar la entidad', error.message);
|
||||
private deleteEntityorClient(uuid: string, type: string): void {
|
||||
if (!this.selectedNode) return;
|
||||
|
||||
const parentNode = this.selectedNode?.id
|
||||
? this.findParentNode(this.treeDataSource.data, this.selectedNode.id)
|
||||
: null;
|
||||
|
||||
this.dataService.deleteElement(uuid, type).subscribe({
|
||||
next: () => {
|
||||
const entityType = type === NodeType.Client ? 'Cliente' : 'Entidad';
|
||||
const verb = type === NodeType.Client ? 'eliminado' : 'eliminada';
|
||||
|
||||
this.toastr.success(`${entityType} ${verb} exitosamente`);
|
||||
|
||||
if (type === NodeType.Client) {
|
||||
this.refreshData(this.selectedNode?.id);
|
||||
} else if (parentNode) {
|
||||
this.refreshData(parentNode.id);
|
||||
} else {
|
||||
this.refreshData();
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private refreshClientsForNode(node: TreeNode): void {
|
||||
if (!node['@id']) {
|
||||
this.selectedClients.data = [];
|
||||
return;
|
||||
}
|
||||
this.fetchClientsForNode(node);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error deleting entity:', error);
|
||||
const entityType = type === NodeType.Client ? 'cliente' : 'entidad';
|
||||
this.toastr.error(`Error al eliminar el ${entityType}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEditClick(event: MouseEvent, type: string, uuid: string): void {
|
||||
event.stopPropagation();
|
||||
if (type !== NodeType.Client) {
|
||||
this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' });
|
||||
} else {
|
||||
this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
|
||||
}
|
||||
const dialogRef = type !== NodeType.Client
|
||||
? this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
|
||||
: this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
|
||||
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.refreshData(this.selectedNode?.id);
|
||||
});
|
||||
}
|
||||
|
||||
onRoomMap(room: TreeNode | null): void {
|
||||
|
@ -550,8 +572,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
this.treeDataSource.data = filteredData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
onTreeFilterInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const searchTerm = input?.value || '';
|
||||
|
@ -569,7 +589,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
|
|||
this.selectedClients.filter = this.searchTerm;
|
||||
}
|
||||
|
||||
|
||||
public setSelectedNode(node: TreeNode): void {
|
||||
this.selectedNode = node;
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ export interface ClientCollection {
|
|||
|
||||
export interface TreeNode {
|
||||
id?: string
|
||||
uuid?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
'@id'?: string;
|
||||
|
|
|
@ -157,15 +157,21 @@ export class CreateClientComponent implements OnInit {
|
|||
onSubmit(): void {
|
||||
if (this.clientForm.valid) {
|
||||
const formData = this.clientForm.value;
|
||||
|
||||
this.http.post(`${this.baseUrl}/clients`, formData).subscribe(
|
||||
response => {
|
||||
(response) => {
|
||||
this.toastService.success('Cliente creado exitosamente', 'Éxito');
|
||||
this.dialogRef.close(response);
|
||||
this.dialogRef.close({
|
||||
client: response,
|
||||
organizationalUnit: formData.organizationalUnit,
|
||||
});
|
||||
},
|
||||
error => {
|
||||
this.toastService.error('Error al crear el cliente', 'Error');
|
||||
(error) => {
|
||||
this.toastService.error(error.error['hydra:description'], 'Error al crear el cliente');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.toastService.error('Formulario inválido. Por favor, revise los campos obligatorios.', 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ export class CreateMultipleClientComponent implements OnInit{
|
|||
displayedColumns: string[] = ['name', 'ip', 'mac'];
|
||||
showTextarea: boolean = true;
|
||||
organizationalUnit: any;
|
||||
regex: RegExp = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([a-zA-Z0-9]{2}(:[a-zA-Z0-9]{2}){5});\s+fixed-address\s+((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?));\s+\}/g;
|
||||
|
||||
constructor(
|
||||
@Optional() private dialogRef: MatDialogRef<CreateMultipleClientComponent>,
|
||||
|
@ -57,15 +58,14 @@ export class CreateMultipleClientComponent implements OnInit{
|
|||
|
||||
reader.onload = (e: any) => {
|
||||
const textData = e.target.result;
|
||||
const regex = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([\da-fA-F:]+);\s+fixed-address\s+([\d.]+);\s+\}/g;
|
||||
let match;
|
||||
const clients = [];
|
||||
|
||||
while ((match = regex.exec(textData)) !== null) {
|
||||
while ((match = this.regex.exec(textData)) !== null) {
|
||||
clients.push({
|
||||
name: match[1],
|
||||
mac: match[2],
|
||||
ip: match[3]
|
||||
ip: match[4]
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -84,15 +84,14 @@ export class CreateMultipleClientComponent implements OnInit{
|
|||
}
|
||||
|
||||
onTextarea(text: string): void {
|
||||
const regex = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([\da-fA-F:]+);\s+fixed-address\s+([\d.]+);\s+\}/g;
|
||||
let match;
|
||||
const clients = [];
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
while ((match = this.regex.exec(text)) !== null) {
|
||||
clients.push({
|
||||
name: match[1],
|
||||
mac: match[2],
|
||||
ip: match[3]
|
||||
ip: match[4]
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -126,7 +125,10 @@ export class CreateMultipleClientComponent implements OnInit{
|
|||
);
|
||||
});
|
||||
this.uploadedClients = [];
|
||||
this.dialogRef.close();
|
||||
this.dialogRef.close({
|
||||
success: true,
|
||||
organizationalUnit: this.organizationalUnit,
|
||||
});
|
||||
} else {
|
||||
this.toastService.error('No hay clientes cargados para añadir', 'Error');
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ export class CreateOrganizationalUnitComponent implements OnInit {
|
|||
selectedCalendarUuid: string | null = null;
|
||||
|
||||
|
||||
@Output() unitAdded = new EventEmitter();
|
||||
@Output() unitAdded = new EventEmitter<{ uuid: string; name: string }>();
|
||||
|
||||
constructor(
|
||||
private _formBuilder: FormBuilder,
|
||||
|
@ -160,8 +160,8 @@ export class CreateOrganizationalUnitComponent implements OnInit {
|
|||
|
||||
this.http.post<any>(postUrl, formData, { headers }).subscribe(
|
||||
response => {
|
||||
this.unitAdded.emit();
|
||||
this.dialogRef.close();
|
||||
this.unitAdded.emit(response);
|
||||
this.dialogRef.close(response);
|
||||
this.toastService.success('Unidad creada exitosamente', 'Éxito');
|
||||
this.openSnackBar(false, 'Unidad creada exitosamente');
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue