Feat: dashboard con gráficos, estructura CBS/Portafolios, semáforo de proyectos y cartera de especialistas

This commit is contained in:
Nachou-u
2026-08-20 18:54:58 -04:00
parent 8c0b435555
commit b692614857
25 changed files with 1717 additions and 159 deletions
+235 -5
View File
@@ -12,15 +12,27 @@
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<div class="form-group" style="position: relative;">
<label for="{{ form.project.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Centro de Costo / Proyecto</label>
{{ form.project }}
<div class="custom-select-wrapper" style="position: relative;">
{{ form.project }}
<input type="text" id="project-search-input" placeholder="Escriba código o nombre del proyecto..." autocomplete="off">
<div id="project-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.project.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.project.errors }}</div>{% endif %}
</div>
<div class="form-group">
<div class="form-group" style="position: relative;">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría Contable</label>
{{ form.category }}
<div class="custom-select-wrapper" style="position: relative;">
{{ form.category }}
<input type="text" id="category-search-input" placeholder="Escriba código o nombre para buscar..." autocomplete="off">
<div id="category-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.category.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>{% endif %}
</div>
</div>
@@ -29,10 +41,32 @@
<div class="form-group">
<label for="{{ form.amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Neto (CLP)</label>
{{ form.amount_clp }}
<small style="color: var(--efintel-gray); font-size: 0.7rem;">El equivalente en USD será calculado automáticamente por el sistema.</small>
{% if form.amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.tax_type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Impuesto</label>
{{ form.tax_type }}
{% if form.tax_type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_type.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.tax_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Impuesto (CLP)</label>
{{ form.tax_amount_clp }}
{% if form.tax_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.total_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Total (CLP)</label>
{{ form.total_amount_clp }}
<small style="color: var(--efintel-gray); font-size: 0.7rem;">El equivalente en USD será calculado automáticamente por el sistema.</small>
{% if form.total_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.total_amount_clp.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.currency.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Moneda Original</label>
{{ form.currency }}
@@ -110,5 +144,201 @@
el.style.fontSize = '0.9rem';
el.style.boxSizing = 'border-box';
});
const amountInput = document.getElementById('id_amount_clp');
const taxTypeSelect = document.getElementById('id_tax_type');
const taxAmountInput = document.getElementById('id_tax_amount_clp');
const totalAmountInput = document.getElementById('id_total_amount_clp');
function calculateTaxes() {
const amount = parseFloat(amountInput.value) || 0;
const taxType = taxTypeSelect.value;
let taxAmount = 0;
if (taxType === 'IVA_19') {
taxAmount = amount * 0.19;
} else if (taxType === 'HONORARIOS_13_75') {
taxAmount = amount * 0.1375;
}
taxAmountInput.value = taxAmount.toFixed(0);
totalAmountInput.value = (amount + taxAmount).toFixed(0);
}
amountInput.addEventListener('input', calculateTaxes);
taxTypeSelect.addEventListener('change', calculateTaxes);
// Initial calculation
calculateTaxes();
// Custom Searchable Dropdown for Category
const categorySelect = document.getElementById('id_category');
const searchInput = document.getElementById('category-search-input');
const dropdownList = document.getElementById('category-dropdown-list');
if (categorySelect && searchInput && dropdownList) {
// Hide original select (make it display none so form data is still submitted)
categorySelect.style.display = 'none';
// Extract options from the django-generated select
const options = Array.from(categorySelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
// Render option list dynamically
function renderList(filteredOptions) {
dropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
dropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron categorías</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.cursor = 'pointer';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
// Mark parent categories differently (they usually have a badge in representation or just end with standard code)
// Note: we can detect if it's a main partida (ends in '000: ' or contains 'Partida')
const isParent = opt.text.includes('1000: ') || opt.text.includes('2000: ') || opt.text.includes('3000: ') || opt.text.includes('4000: ');
if (isParent) {
item.style.fontWeight = '700';
item.style.background = '#f8fafc';
item.style.color = 'var(--efintel-petroleum)';
}
item.addEventListener('click', () => {
categorySelect.value = opt.value;
searchInput.value = opt.text;
dropdownList.style.display = 'none';
categorySelect.dispatchEvent(new Event('change'));
});
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--efintel-light)';
item.style.color = 'var(--efintel-blue)';
});
item.addEventListener('mouseleave', () => {
item.style.background = isParent ? '#f8fafc' : 'white';
item.style.color = isParent ? 'var(--efintel-petroleum)' : 'inherit';
});
dropdownList.appendChild(item);
});
}
// Initialize input value if edit mode
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
if (selectedOption && selectedOption.value !== '') {
searchInput.value = selectedOption.textContent;
}
// Show dropdown list on focus
searchInput.addEventListener('focus', () => {
renderList(options);
dropdownList.style.display = 'block';
});
// Dynamic filtering
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = options.filter(opt => opt.text.toLowerCase().includes(query));
renderList(filtered);
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !dropdownList.contains(e.target)) {
dropdownList.style.display = 'none';
}
});
}
// Custom Searchable Dropdown for Project
const projectSelect = document.getElementById('id_project');
const projectSearchInput = document.getElementById('project-search-input');
const projectDropdownList = document.getElementById('project-dropdown-list');
if (projectSelect && projectSearchInput && projectDropdownList) {
projectSelect.style.display = 'none';
const projectOptions = Array.from(projectSelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
function renderProjectList(filteredOptions) {
projectDropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
projectDropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron proyectos</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.cursor = 'pointer';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
const isPortfolio = opt.text.startsWith('PORT-');
if (isPortfolio) {
item.style.fontWeight = '700';
item.style.background = '#f8fafc';
item.style.color = 'var(--efintel-petroleum)';
}
item.addEventListener('click', () => {
projectSelect.value = opt.value;
projectSearchInput.value = opt.text;
projectDropdownList.style.display = 'none';
projectSelect.dispatchEvent(new Event('change'));
});
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--efintel-light)';
item.style.color = 'var(--efintel-blue)';
});
item.addEventListener('mouseleave', () => {
item.style.background = isPortfolio ? '#f8fafc' : 'white';
item.style.color = isPortfolio ? 'var(--efintel-petroleum)' : 'inherit';
});
projectDropdownList.appendChild(item);
});
}
const selectedProjectOption = projectSelect.options[projectSelect.selectedIndex];
if (selectedProjectOption && selectedProjectOption.value !== '') {
projectSearchInput.value = selectedProjectOption.textContent;
}
projectSearchInput.addEventListener('focus', () => {
renderProjectList(projectOptions);
projectDropdownList.style.display = 'block';
});
projectSearchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = projectOptions.filter(opt => opt.text.toLowerCase().includes(query));
renderProjectList(filtered);
});
document.addEventListener('click', (e) => {
if (!projectSearchInput.contains(e.target) && !projectDropdownList.contains(e.target)) {
projectDropdownList.style.display = 'none';
}
});
}
</script>
{% endblock %}