fix: mejorar aspecto visual
This commit is contained in:
parent
541d7bb950
commit
f583f107de
522
index.html
522
index.html
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sistema de Candidatos</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
.token-info {
|
||||
word-break: break-all;
|
||||
|
@ -21,6 +22,29 @@
|
|||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.success-message {
|
||||
background-color: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
color: #155724;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.download-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.download-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.download-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -52,14 +76,15 @@
|
|||
<div class="col-md-6 mb-4">
|
||||
<div class="card" id="token-card">
|
||||
<div class="card-header">
|
||||
<h5>Token de Acceso</h5>
|
||||
<h5>Estado de Autenticación</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="token-section" class="hidden">
|
||||
<div class="token-info">
|
||||
<p><strong>Access Token:</strong> <span id="access-token">-</span></p>
|
||||
<p><strong>Tipo:</strong> <span id="token-type">-</span></p>
|
||||
<p><strong>Expira en:</strong> <span id="expires-in">-</span> segundos</p>
|
||||
<div class="success-message">
|
||||
<p><strong>✓ Autenticación exitosa</strong></p>
|
||||
<p><strong>Token tipo:</strong> <span id="token-type">-</span></p>
|
||||
<p><strong>Token válido por:</strong> <span id="expires-in">-</span> segundos</p>
|
||||
<p><small class="text-muted">El token de acceso se ha guardado de forma segura</small></p>
|
||||
</div>
|
||||
<button id="get-candidates" class="btn btn-success">Obtener Candidatos</button>
|
||||
</div>
|
||||
|
@ -76,6 +101,24 @@
|
|||
<h5>Resultados</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="download-section" class="download-section hidden">
|
||||
<h6 class="mb-3">📥 Descargar Datos</h6>
|
||||
<div class="download-buttons">
|
||||
<button id="download-json" class="btn btn-outline-primary download-btn">
|
||||
📄 JSON
|
||||
</button>
|
||||
<button id="download-csv" class="btn btn-outline-success download-btn">
|
||||
📊 CSV
|
||||
</button>
|
||||
<button id="download-excel" class="btn btn-outline-info download-btn">
|
||||
📈 Excel
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2">
|
||||
Total de registros: <span id="records-count">0</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div id="candidates-section" class="hidden">
|
||||
<h4 class="mb-3">Lista de Candidatos</h4>
|
||||
<div class="table-responsive">
|
||||
|
@ -126,151 +169,368 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
let accessToken = '';
|
||||
const API_BASE_URL = 'http://localhost:5000/api';
|
||||
// ==========================================
|
||||
// CONFIGURACIÓN Y CONSTANTES
|
||||
// ==========================================
|
||||
const CONFIG = {
|
||||
API_BASE_URL: 'http://localhost:5000/api',
|
||||
FILE_PREFIX: 'candidatos'
|
||||
};
|
||||
|
||||
document.getElementById('oauth-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const clientId = document.getElementById('clientId').value;
|
||||
const clientSecret = document.getElementById('clientSecret').value;
|
||||
|
||||
hideAllMessages();
|
||||
|
||||
try {
|
||||
const response = await getAccessToken(clientId, clientSecret);
|
||||
displayTokenInfo(response);
|
||||
} catch (error) {
|
||||
showError(`Error al obtener token: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('get-candidates').addEventListener('click', async function() {
|
||||
hideAllMessages();
|
||||
|
||||
try {
|
||||
const candidates = await getCandidates();
|
||||
displayCandidates(candidates);
|
||||
} catch (error) {
|
||||
showError(`Error al obtener candidatos: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function getAccessToken(clientId, clientSecret) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('grant_type', 'client_credentials');
|
||||
formData.append('client_id', clientId);
|
||||
formData.append('client_secret', clientSecret);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Error en la autenticación');
|
||||
// ==========================================
|
||||
// ESTADO DE LA APLICACIÓN
|
||||
// ==========================================
|
||||
class AppState {
|
||||
constructor() {
|
||||
this.accessToken = '';
|
||||
this.candidatesData = [];
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
setAccessToken(token) {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
getAccessToken() {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
setCandidatesData(data) {
|
||||
this.candidatesData = data;
|
||||
}
|
||||
|
||||
getCandidatesData() {
|
||||
return this.candidatesData;
|
||||
}
|
||||
|
||||
hasToken() {
|
||||
return !!this.accessToken;
|
||||
}
|
||||
|
||||
hasData() {
|
||||
return this.candidatesData.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function getCandidates() {
|
||||
if (!accessToken) {
|
||||
throw new Error('No hay token de acceso disponible');
|
||||
}
|
||||
// ==========================================
|
||||
// SERVICIOS API
|
||||
// ==========================================
|
||||
class ApiService {
|
||||
static async getAccessToken(clientId, clientSecret) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('grant_type', 'client_credentials');
|
||||
formData.append('client_id', clientId);
|
||||
formData.append('client_secret', clientSecret);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/candidatos/obtenerCandidatos`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
const response = await fetch(`${CONFIG.API_BASE_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Error en la autenticación');
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Error al obtener candidatos');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
static async getCandidates(accessToken) {
|
||||
if (!accessToken) {
|
||||
throw new Error('No hay token de acceso disponible');
|
||||
}
|
||||
|
||||
const response = await fetch(`${CONFIG.API_BASE_URL}/candidatos/obtenerCandidatos`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Error al obtener candidatos');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
function displayTokenInfo(tokenData) {
|
||||
accessToken = tokenData.access_token;
|
||||
|
||||
document.getElementById('access-token').textContent = tokenData.access_token;
|
||||
document.getElementById('token-type').textContent = tokenData.token_type;
|
||||
document.getElementById('expires-in').textContent = tokenData.expires_in;
|
||||
|
||||
document.getElementById('token-section').classList.remove('hidden');
|
||||
document.getElementById('no-token-message').classList.add('hidden');
|
||||
}
|
||||
|
||||
function displayCandidates(candidates) {
|
||||
// Mostrar la respuesta JSON completa
|
||||
document.getElementById('api-response').textContent = JSON.stringify(candidates, null, 2);
|
||||
document.getElementById('api-response-section').classList.remove('hidden');
|
||||
|
||||
// Llenar la tabla de candidatos
|
||||
const tableBody = document.getElementById('candidates-table-body');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
candidates.forEach(candidato => {
|
||||
const dem = candidato.demograficos || {};
|
||||
const ubi = dem.ubicacion || {};
|
||||
const form = candidato.formacion || {};
|
||||
const exam = candidato.examen || {};
|
||||
const exp = candidato.experiencia_servicio || {};
|
||||
const fechas = candidato.fechas || {};
|
||||
// ==========================================
|
||||
// UTILIDADES
|
||||
// ==========================================
|
||||
class Utils {
|
||||
static getCurrentDateTime() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
|
||||
const row = document.createElement('tr');
|
||||
return `${year}${month}${day}_${hours}${minutes}`;
|
||||
}
|
||||
|
||||
static flattenObject(obj, prefix = '') {
|
||||
const flattened = {};
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${candidato.id_candidato ?? ''}</td>
|
||||
<td>${candidato.nombre_completo ?? ''}</td>
|
||||
<td>${candidato.contacto?.correo ?? ''}</td>
|
||||
<td>${candidato.contacto?.telefono ?? ''}</td>
|
||||
<td>${dem.genero ?? ''}</td>
|
||||
<td>${dem.rango_edad ?? ''}</td>
|
||||
<td>${dem.tipo_identificacion ?? ''}</td>
|
||||
<td>${ubi.pais ?? ''}</td>
|
||||
<td>${ubi.estado ?? ''}</td>
|
||||
<td>${ubi.municipio ?? ''}</td>
|
||||
<td>${ubi.colonia ?? ''}</td>
|
||||
<td>${form.nivel_estudio ?? ''}</td>
|
||||
<td>${form.giro ?? ''}</td>
|
||||
<td>${form.nombre_empresa_institucion ?? ''}</td>
|
||||
<td>${exam.id_examen ?? ''}</td>
|
||||
<td>${exam.nombre_examen ?? ''}</td>
|
||||
<td>${exam.motivo ?? ''}</td>
|
||||
<td>${exp.calificacion_servicio ?? ''}</td>
|
||||
<td>${exp.consentimiento_publicidad !== undefined ? (exp.consentimiento_publicidad ? 'Sí' : 'No') : ''}</td>
|
||||
<td>${fechas.entrada ?? ''}</td>
|
||||
<td>${fechas.salida ?? ''}</td>
|
||||
`;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = prefix ? `${prefix}_${key}` : key;
|
||||
|
||||
if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
Object.assign(flattened, Utils.flattenObject(obj[key], newKey));
|
||||
} else {
|
||||
flattened[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
document.getElementById('candidates-section').classList.remove('hidden');
|
||||
document.getElementById('no-results-message').classList.add('hidden');
|
||||
return flattened;
|
||||
}
|
||||
|
||||
static convertToCSV(data) {
|
||||
if (!data.length) return '';
|
||||
|
||||
const flatData = data.map(candidato => Utils.flattenObject(candidato));
|
||||
const headers = Object.keys(flatData[0]);
|
||||
|
||||
const csvHeaders = headers.join(',');
|
||||
const csvRows = flatData.map(row =>
|
||||
headers.map(header => {
|
||||
const value = row[header];
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('\n') || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value || '';
|
||||
}).join(',')
|
||||
);
|
||||
|
||||
return [csvHeaders, ...csvRows].join('\n');
|
||||
}
|
||||
|
||||
static downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorElement = document.getElementById('error-message');
|
||||
errorElement.textContent = message;
|
||||
document.getElementById('error-section').classList.remove('hidden');
|
||||
document.getElementById('no-results-message').classList.add('hidden');
|
||||
// ==========================================
|
||||
// CONTROLADOR DE DESCARGA
|
||||
// ==========================================
|
||||
class DownloadController {
|
||||
constructor(appState) {
|
||||
this.appState = appState;
|
||||
}
|
||||
|
||||
downloadJSON() {
|
||||
if (!this.appState.hasData()) {
|
||||
alert('No hay datos para descargar');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(this.appState.getCandidatesData(), null, 2);
|
||||
const filename = `${CONFIG.FILE_PREFIX}_${Utils.getCurrentDateTime()}.json`;
|
||||
Utils.downloadFile(dataStr, filename, 'application/json');
|
||||
}
|
||||
|
||||
downloadCSV() {
|
||||
if (!this.appState.hasData()) {
|
||||
alert('No hay datos para descargar');
|
||||
return;
|
||||
}
|
||||
|
||||
const csvData = Utils.convertToCSV(this.appState.getCandidatesData());
|
||||
const filename = `${CONFIG.FILE_PREFIX}_${Utils.getCurrentDateTime()}.csv`;
|
||||
Utils.downloadFile(csvData, filename, 'text/csv;charset=utf-8;');
|
||||
}
|
||||
|
||||
downloadExcel() {
|
||||
if (!this.appState.hasData()) {
|
||||
alert('No hay datos para descargar');
|
||||
return;
|
||||
}
|
||||
|
||||
const flatData = this.appState.getCandidatesData().map(candidato => Utils.flattenObject(candidato));
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.json_to_sheet(flatData);
|
||||
|
||||
const cols = Object.keys(flatData[0]).map(() => ({ wch: 15 }));
|
||||
ws['!cols'] = cols;
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Candidatos');
|
||||
const filename = `${CONFIG.FILE_PREFIX}_${Utils.getCurrentDateTime()}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
}
|
||||
|
||||
function hideAllMessages() {
|
||||
document.getElementById('error-section').classList.add('hidden');
|
||||
document.getElementById('api-response-section').classList.add('hidden');
|
||||
// ==========================================
|
||||
// CONTROLADOR DE UI
|
||||
// ==========================================
|
||||
class UIController {
|
||||
constructor() {
|
||||
this.elements = {
|
||||
tokenType: document.getElementById('token-type'),
|
||||
expiresIn: document.getElementById('expires-in'),
|
||||
tokenSection: document.getElementById('token-section'),
|
||||
noTokenMessage: document.getElementById('no-token-message'),
|
||||
downloadSection: document.getElementById('download-section'),
|
||||
recordsCount: document.getElementById('records-count'),
|
||||
candidatesSection: document.getElementById('candidates-section'),
|
||||
candidatesTableBody: document.getElementById('candidates-table-body'),
|
||||
apiResponseSection: document.getElementById('api-response-section'),
|
||||
apiResponse: document.getElementById('api-response'),
|
||||
errorSection: document.getElementById('error-section'),
|
||||
errorMessage: document.getElementById('error-message'),
|
||||
noResultsMessage: document.getElementById('no-results-message')
|
||||
};
|
||||
}
|
||||
|
||||
displayTokenInfo(tokenData) {
|
||||
this.elements.tokenType.textContent = tokenData.token_type;
|
||||
this.elements.expiresIn.textContent = tokenData.expires_in;
|
||||
|
||||
this.elements.tokenSection.classList.remove('hidden');
|
||||
this.elements.noTokenMessage.classList.add('hidden');
|
||||
|
||||
console.log('Token obtenido y almacenado de forma segura');
|
||||
}
|
||||
|
||||
displayCandidates(candidates) {
|
||||
this.elements.apiResponse.textContent = JSON.stringify(candidates, null, 2);
|
||||
this.elements.apiResponseSection.classList.remove('hidden');
|
||||
|
||||
this.elements.downloadSection.classList.remove('hidden');
|
||||
this.elements.recordsCount.textContent = candidates.length;
|
||||
|
||||
this._fillCandidatesTable(candidates);
|
||||
|
||||
this.elements.candidatesSection.classList.remove('hidden');
|
||||
this.elements.noResultsMessage.classList.add('hidden');
|
||||
}
|
||||
|
||||
_fillCandidatesTable(candidates) {
|
||||
this.elements.candidatesTableBody.innerHTML = '';
|
||||
|
||||
candidates.forEach(candidato => {
|
||||
const dem = candidato.demograficos || {};
|
||||
const ubi = dem.ubicacion || {};
|
||||
const form = candidato.formacion || {};
|
||||
const exam = candidato.examen || {};
|
||||
const exp = candidato.experiencia_servicio || {};
|
||||
const fechas = candidato.fechas || {};
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${candidato.id_candidato ?? ''}</td>
|
||||
<td>${candidato.nombre_completo ?? ''}</td>
|
||||
<td>${candidato.contacto?.correo ?? ''}</td>
|
||||
<td>${candidato.contacto?.telefono ?? ''}</td>
|
||||
<td>${dem.genero ?? ''}</td>
|
||||
<td>${dem.rango_edad ?? ''}</td>
|
||||
<td>${dem.tipo_identificacion ?? ''}</td>
|
||||
<td>${ubi.pais ?? ''}</td>
|
||||
<td>${ubi.estado ?? ''}</td>
|
||||
<td>${ubi.municipio ?? ''}</td>
|
||||
<td>${ubi.colonia ?? ''}</td>
|
||||
<td>${form.nivel_estudio ?? ''}</td>
|
||||
<td>${form.giro ?? ''}</td>
|
||||
<td>${form.nombre_empresa_institucion ?? ''}</td>
|
||||
<td>${exam.id_examen ?? ''}</td>
|
||||
<td>${exam.nombre_examen ?? ''}</td>
|
||||
<td>${exam.motivo ?? ''}</td>
|
||||
<td>${exp.calificacion_servicio ?? ''}</td>
|
||||
<td>${exp.consentimiento_publicidad !== undefined ? (exp.consentimiento_publicidad ? 'Sí' : 'No') : ''}</td>
|
||||
<td>${fechas.entrada ?? ''}</td>
|
||||
<td>${fechas.salida ?? ''}</td>
|
||||
`;
|
||||
|
||||
this.elements.candidatesTableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.elements.errorMessage.textContent = message;
|
||||
this.elements.errorSection.classList.remove('hidden');
|
||||
this.elements.noResultsMessage.classList.add('hidden');
|
||||
}
|
||||
|
||||
hideAllMessages() {
|
||||
this.elements.errorSection.classList.add('hidden');
|
||||
this.elements.apiResponseSection.classList.add('hidden');
|
||||
this.elements.downloadSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CONTROLADOR PRINCIPAL DE LA APLICACIÓN
|
||||
// ==========================================
|
||||
class AppController {
|
||||
constructor() {
|
||||
this.appState = new AppState();
|
||||
this.uiController = new UIController();
|
||||
this.downloadController = new DownloadController(this.appState);
|
||||
|
||||
this._initializeEventListeners();
|
||||
}
|
||||
|
||||
_initializeEventListeners() {
|
||||
// OAuth form
|
||||
document.getElementById('oauth-form').addEventListener('submit', (e) => this._handleOAuthSubmit(e));
|
||||
|
||||
// Get candidates button
|
||||
document.getElementById('get-candidates').addEventListener('click', () => this._handleGetCandidates());
|
||||
|
||||
// Download buttons
|
||||
document.getElementById('download-json').addEventListener('click', () => this.downloadController.downloadJSON());
|
||||
document.getElementById('download-csv').addEventListener('click', () => this.downloadController.downloadCSV());
|
||||
document.getElementById('download-excel').addEventListener('click', () => this.downloadController.downloadExcel());
|
||||
}
|
||||
|
||||
async _handleOAuthSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const clientId = document.getElementById('clientId').value;
|
||||
const clientSecret = document.getElementById('clientSecret').value;
|
||||
|
||||
this.uiController.hideAllMessages();
|
||||
|
||||
try {
|
||||
const response = await ApiService.getAccessToken(clientId, clientSecret);
|
||||
this.appState.setAccessToken(response.access_token);
|
||||
this.uiController.displayTokenInfo(response);
|
||||
} catch (error) {
|
||||
this.uiController.showError(`Error al obtener token: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleGetCandidates() {
|
||||
this.uiController.hideAllMessages();
|
||||
|
||||
try {
|
||||
const candidates = await ApiService.getCandidates(this.appState.getAccessToken());
|
||||
this.appState.setCandidatesData(candidates);
|
||||
this.uiController.displayCandidates(candidates);
|
||||
} catch (error) {
|
||||
this.uiController.showError(`Error al obtener candidatos: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INICIALIZACIÓN DE LA APLICACIÓN
|
||||
// ==========================================
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new AppController();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
Loading…
Reference in New Issue