Skip to content

Instantly share code, notes, and snippets.

@memogs
Created June 24, 2025 00:34
Show Gist options
  • Save memogs/7ee03614436ca3551be6d51ce291ecf2 to your computer and use it in GitHub Desktop.
Save memogs/7ee03614436ca3551be6d51ce291ecf2 to your computer and use it in GitHub Desktop.
// Function to initialize the entire application
function startCostingApp() {
// ACCESS CONTROL LOGIC
const accessOverlay = document.getElementById('access-overlay');
const mainContent = document.getElementById('main-content');
const accessForm = document.getElementById('access-form');
const passphraseInput = document.getElementById('passphrase-input');
const errorMessage = document.getElementById('error-message');
if (accessForm) {
accessForm.addEventListener('submit', (e) => {
e.preventDefault();
passphraseInput.classList.remove('error');
errorMessage.textContent = '';
if (passphraseInput.value === 'costosatelier') {
accessOverlay.classList.add('opacity-0');
setTimeout(() => {
accessOverlay.style.display = 'none'; // Use display none for performance
mainContent.classList.remove('hidden');
initApp(); // Initialize the main application after successful login
}, 300);
} else {
passphraseInput.classList.add('error');
errorMessage.textContent = 'Frase incorrecta. Inténtalo de nuevo.';
setTimeout(() => passphraseInput.classList.remove('error'), 500);
passphraseInput.value = '';
}
});
}
// MAIN APPLICATION LOGIC - Encapsulated in a function
const initApp = () => {
// STATE MANAGEMENT
let state = {
costosDirectos: [
{ id: 1, partida: "Cimentación", concepto: "Acero de refuerzo #4", unidad: "kg", cantidad: 1500, pu: 28.50 },
{ id: 2, partida: "Cimentación", concepto: "Concreto f'c=250", unidad: "m³", cantidad: 85, pu: 2200.00 },
{ id: 3, partida: "Albañilería", concepto: "Muro de tabique rojo", unidad: "m²", cantidad: 350, pu: 310.00 },
{ id: 4, partida: "Acabados", concepto: "Pintura vinílica", unidad: "L", cantidad: 200, pu: 150.00 },
{ id: 5, partida: "Estructura", concepto: "Viga de acero IPR 12\"", unidad: "m", cantidad: 120, pu: 1800.00 },
],
costosIndirectos: [
{ id: 1, tipo: "Campo", concepto: "Renta de Oficina de Obra", unidades: 1, periodo: 6, costoMensual: 8000.00 },
{ id: 2, tipo: "Campo", concepto: "Sueldo Residente de Obra", unidades: 1, periodo: 6, costoMensual: 35000.00 },
{ id: 3, tipo: "Oficina", concepto: "Sueldo Contador (Prorrateo)", unidades: 1, periodo: 6, costoMensual: 5000.00 },
{ id: 4, tipo: "Campo", concepto: "Fianzas y Seguros", unidades: 1, periodo: 1, costoMensual: 50000.00 },
],
parametros: {
contingencia: 0.05,
utilidad: 0.15,
iva: 0.16
}
};
// DOM ELEMENTS
const cdTbody = document.getElementById('cd-tbody');
const ciTbody = document.getElementById('ci-tbody');
const historyTbody = document.getElementById('history-tbody');
const btnCd = document.getElementById('btn-cd');
const btnCi = document.getElementById('btn-ci');
const cdTableContainer = document.getElementById('cd-table-container');
const ciTableContainer = document.getElementById('ci-table-container');
const contingenciaSlider = document.getElementById('contingenciaSlider');
const utilidadSlider = document.getElementById('utilidadSlider');
const ivaSlider = document.getElementById('ivaSlider');
const contingenciaValue = document.getElementById('contingenciaValue');
const utilidadValue = document.getElementById('utilidadValue');
const ivaValue = document.getElementById('ivaValue');
const totalCDEl = document.getElementById('totalCD');
const totalCIEl = document.getElementById('totalCI');
const costoProduccionEl = document.getElementById('costoProduccion');
const montoContingenciaEl = document.getElementById('montoContingencia');
const subtotal1El = document.getElementById('subtotal1');
const montoUtilidadEl = document.getElementById('montoUtilidad');
const subtotal2El = document.getElementById('subtotal2');
const montoIvaEl = document.getElementById('montoIva');
const precioVentaEl = document.getElementById('precioVenta');
let priceChart, topCostsChart;
const formatCurrency = (value) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(value);
const getCDTotal = (item) => (item.cantidad || 0) * (item.pu || 0);
const getCITotal = (item) => (item.unidades || 0) * (item.periodo || 0) * (item.costoMensual || 0);
const renderTables = () => {
cdTbody.innerHTML = state.costosDirectos.map(item => `
<tr data-id="${item.id}">
<td class="px-4 py-2"><input type="text" class="table-input" data-field="partida" value="${item.partida}"></td>
<td class="px-4 py-2"><input type="text" class="table-input" data-field="concepto" value="${item.concepto}"></td>
<td class="px-4 py-2"><input type="text" class="table-input w-16" data-field="unidad" value="${item.unidad}"></td>
<td class="px-4 py-2"><input type="number" class="table-input w-20" data-field="cantidad" value="${item.cantidad}" min="0" step="any"></td>
<td class="px-4 py-2"><input type="number" class="table-input w-24" data-field="pu" value="${item.pu.toFixed(2)}" min="0" step="any"></td>
<td class="px-4 py-2 font-medium text-slate-700">${formatCurrency(getCDTotal(item))}</td>
<td class="px-2 py-2"><button class="text-red-500 hover:text-red-700 remove-cd-row">&times;</button></td>
</tr>`).join('');
ciTbody.innerHTML = state.costosIndirectos.map(item => `
<tr data-id="${item.id}">
<td class="px-4 py-2"><input type="text" class="table-input" data-field="tipo" value="${item.tipo}"></td>
<td class="px-4 py-2"><input type="text" class="table-input" data-field="concepto" value="${item.concepto}"></td>
<td class="px-4 py-2"><input type="number" class="table-input w-20" data-field="unidades" value="${item.unidades}" min="0"></td>
<td class="px-4 py-2"><input type="number" class="table-input w-24" data-field="periodo" value="${item.periodo}" min="0"></td>
<td class="px-4 py-2"><input type="number" class="table-input w-28" data-field="costoMensual" value="${item.costoMensual.toFixed(2)}" min="0" step="any"></td>
<td class="px-4 py-2 font-medium text-slate-700">${formatCurrency(getCITotal(item))}</td>
<td class="px-2 py-2"><button class="text-red-500 hover:text-red-700 remove-ci-row">&times;</button></td>
</tr>`).join('');
};
const calculateAll = () => {
const totalCD = state.costosDirectos.reduce((acc, item) => acc + getCDTotal(item), 0);
const totalCI = state.costosIndirectos.reduce((acc, item) => acc + getCITotal(item), 0);
const costoProduccion = totalCD + totalCI;
const montoContingencia = costoProduccion * state.parametros.contingencia;
const subtotal1 = costoProduccion + montoContingencia;
const montoUtilidad = subtotal1 * state.parametros.utilidad;
const subtotal2 = subtotal1 + montoUtilidad;
const montoIva = subtotal2 * state.parametros.iva;
const precioVenta = subtotal2 + montoIva;
return { totalCD, totalCI, costoProduccion, montoContingencia, subtotal1, montoUtilidad, subtotal2, montoIva, precioVenta };
}
const renderConsolidadoAndCharts = () => {
const calcs = calculateAll();
totalCDEl.textContent = formatCurrency(calcs.totalCD);
totalCIEl.textContent = formatCurrency(calcs.totalCI);
costoProduccionEl.textContent = formatCurrency(calcs.costoProduccion);
montoContingenciaEl.textContent = formatCurrency(calcs.montoContingencia);
subtotal1El.textContent = formatCurrency(calcs.subtotal1);
montoUtilidadEl.textContent = formatCurrency(calcs.montoUtilidad);
subtotal2El.textContent = formatCurrency(calcs.subtotal2);
montoIvaEl.textContent = formatCurrency(calcs.montoIva);
precioVentaEl.textContent = formatCurrency(calcs.precioVenta);
renderPriceChart({ totalCD: calcs.totalCD, totalCI: calcs.totalCI, montoContingencia: calcs.montoContingencia, montoUtilidad: calcs.montoUtilidad, montoIva: calcs.montoIva });
renderTopCostsChart();
};
const renderPriceChart = (data) => {
const ctx = document.getElementById('priceCompositionChart').getContext('2d');
if (priceChart) priceChart.destroy();
priceChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Costos Directos', 'Costos Indirectos', 'Contingencia', 'Utilidad', 'IVA'],
datasets: [{ data: [data.totalCD, data.totalCI, data.montoContingencia, data.montoUtilidad, data.montoIva], backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444'], borderColor: '#f8fafc', borderWidth: 2 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: (context) => `${context.label}: ${formatCurrency(context.raw)}` } } } }
});
};
const renderTopCostsChart = () => {
const sortedCosts = [...state.costosDirectos].map(item => ({ ...item, total: getCDTotal(item) })).sort((a, b) => b.total - a.total).slice(0, 5);
const ctx = document.getElementById('topCostsChart').getContext('2d');
if (topCostsChart) topCostsChart.destroy();
topCostsChart = new Chart(ctx, {
type: 'bar',
data: { labels: sortedCosts.map(item => item.concepto.length > 20 ? item.concepto.substring(0, 18) + '...' : item.concepto), datasets: [{ label: 'Costo Total', data: sortedCosts.map(item => item.total), backgroundColor: '#3b82f6', borderRadius: 4 }] },
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (context) => formatCurrency(context.raw) } } }, scales: { x: { beginAtZero: true } } }
});
};
const handleTableUpdate = (e, costType) => {
const row = e.target.closest('tr');
if (!row) return;
const id = parseInt(row.dataset.id);
const field = e.target.dataset.field;
const value = e.target.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value;
const item = state[costType].find(i => i.id === id);
if (item) { item[field] = value; renderTables(); renderConsolidadoAndCharts(); }
};
const addRow = (costType) => {
const newId = Date.now();
if (costType === 'costosDirectos') { state.costosDirectos.push({ id: newId, partida: "", concepto: "", unidad: "", cantidad: 0, pu: 0 }); }
else { state.costosIndirectos.push({ id: newId, tipo: "", concepto: "", unidades: 1, periodo: 1, costoMensual: 0 }); }
renderTables(); renderConsolidadoAndCharts();
};
const removeRow = (e, costType) => {
const row = e.target.closest('tr');
if (!row) return;
const id = parseInt(row.dataset.id);
state[costType] = state[costType].filter(i => i.id !== id);
renderTables(); renderConsolidadoAndCharts();
};
const handleSliderUpdate = (e) => {
const { id, value } = e.target;
const percentage = parseFloat(value) / 100;
if (id === 'contingenciaSlider') { state.parametros.contingencia = percentage; contingenciaValue.textContent = `${value}.0%`; }
if (id === 'utilidadSlider') { state.parametros.utilidad = percentage; utilidadValue.textContent = `${value}.0%`; }
if (id === 'ivaSlider') { state.parametros.iva = percentage; ivaValue.textContent = `${value}.0%`; }
renderConsolidadoAndCharts();
};
const switchTab = (activeBtn, inactiveBtn, activeContainer, inactiveContainer) => {
activeBtn.classList.add('border-blue-500', 'text-blue-600', 'font-semibold');
activeBtn.classList.remove('border-transparent', 'text-slate-500', 'hover:text-slate-700');
inactiveBtn.classList.add('border-transparent', 'text-slate-500', 'hover:text-slate-700');
inactiveBtn.classList.remove('border-blue-500', 'text-blue-600', 'font-semibold');
activeContainer.classList.remove('hidden');
inactiveContainer.classList.add('hidden');
};
// PDF & History Logic
const saveReportToHistory = (reportData) => {
let history = JSON.parse(localStorage.getItem('costReportHistory') || '[]');
history.unshift(reportData); // Add to the beginning
localStorage.setItem('costReportHistory', JSON.stringify(history));
renderReportHistory();
};
const renderReportHistory = () => {
let history = JSON.parse(localStorage.getItem('costReportHistory') || '[]');
const filterDate = document.getElementById('filter-date').value;
const filterProject = document.getElementById('filter-project').value.toLowerCase();
const filterClient = document.getElementById('filter-client').value.toLowerCase();
const filteredHistory = history.filter(item => {
const itemDate = new Date(item.date).toISOString().split('T')[0];
const matchDate = !filterDate || itemDate === filterDate;
const matchProject = !filterProject || item.projectName.toLowerCase().includes(filterProject);
const matchClient = !filterClient || item.clientName.toLowerCase().includes(filterClient);
return matchDate && matchProject && matchClient;
});
historyTbody.innerHTML = filteredHistory.map(item => `
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">${new Date(item.date).toLocaleDateString()}</td>
<td class="px-4 py-3">${item.projectName}</td>
<td class="px-4 py-3">${item.clientName}</td>
<td class="px-4 py-3">${item.version}</td>
<td class="px-4 py-3 font-medium">${formatCurrency(item.total)}</td>
</tr>
`).join('');
};
const generatePDF = () => {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
const calcs = calculateAll();
const projectName = document.getElementById('projectName').value;
const clientName = document.getElementById('clientName').value;
const creationDate = new Date(document.getElementById('creationDate').value).toLocaleDateString();
const version = document.getElementById('version').value;
doc.setFontSize(18);
doc.text(`Reporte de Costos: ${projectName}`, 14, 22);
doc.setFontSize(11);
doc.text(`Cliente: ${clientName}`, 14, 30);
doc.text(`Fecha: ${creationDate}`, 120, 30);
doc.text(`Versión: ${version}`, 160, 30);
doc.autoTable({
startY: 40,
head: [['Concepto', 'Monto']],
body: [
['Total Costos Directos (CD)', formatCurrency(calcs.totalCD)],
['Total Costos Indirectos (CI)', formatCurrency(calcs.totalCI)],
{ content: 'Costo Total de Producción', styles: { fontStyle: 'bold' } },
[ { content: ''}, { content: formatCurrency(calcs.costoProduccion), styles: { fontStyle: 'bold' } } ],
['Contingencia', formatCurrency(calcs.montoContingencia)],
['Utilidad', formatCurrency(calcs.montoUtilidad)],
['IVA', formatCurrency(calcs.montoIva)],
{ content: 'Precio de Venta Final', styles: { fontStyle: 'bold', fillColor: [22, 163, 74], textColor: 255 } },
[ { content: ''}, { content: formatCurrency(calcs.precioVenta), styles: { fontStyle: 'bold' } } ],
],
theme: 'grid'
});
let finalY = doc.lastAutoTable.finalY || 10;
doc.autoTable({
startY: finalY + 10,
head: [['Partida', 'Concepto', 'Unidad', 'Cantidad', 'P.U.', 'Total']],
body: state.costosDirectos.map(i => [i.partida, i.concepto, i.unidad, i.cantidad, formatCurrency(i.pu), formatCurrency(getCDTotal(i))]),
didDrawPage: (data) => { finalY = data.cursor.y; }
});
doc.autoTable({
startY: finalY + 10,
head: [['Tipo', 'Concepto', 'Unidades', 'Periodo', 'Costo Mensual', 'Total']],
body: state.costosIndirectos.map(i => [i.tipo, i.concepto, i.unidades, i.periodo, formatCurrency(i.costoMensual), formatCurrency(getCITotal(i))])
});
doc.save(`Reporte_${projectName}_${new Date().toISOString().split('T')[0]}.pdf`);
saveReportToHistory({
date: new Date().toISOString(),
projectName,
clientName,
version,
total: calcs.precioVenta
});
};
// Initial Setup and Event Listeners
document.getElementById('creationDate').valueAsDate = new Date();
cdTbody.addEventListener('input', (e) => handleTableUpdate(e, 'costosDirectos'));
ciTbody.addEventListener('input', (e) => handleTableUpdate(e, 'costosIndirectos'));
cdTbody.addEventListener('click', (e) => { if (e.target.classList.contains('remove-cd-row')) removeRow(e, 'costosDirectos') });
ciTbody.addEventListener('click', (e) => { if (e.target.classList.contains('remove-ci-row')) removeRow(e, 'costosIndirectos') });
document.getElementById('add-cd-row').addEventListener('click', () => addRow('costosDirectos'));
document.getElementById('add-ci-row').addEventListener('click', () => addRow('costosIndirectos'));
contingenciaSlider.addEventListener('input', handleSliderUpdate);
utilidadSlider.addEventListener('input', handleSliderUpdate);
ivaSlider.addEventListener('input', handleSliderUpdate);
btnCd.addEventListener('click', () => switchTab(btnCd, btnCi, cdTableContainer, ciTableContainer));
btnCi.addEventListener('click', () => switchTab(btnCi, btnCd, ciTableContainer, cdTableContainer));
document.getElementById('generate-pdf-btn').addEventListener('click', generatePDF);
document.getElementById('filter-date').addEventListener('input', renderReportHistory);
document.getElementById('filter-project').addEventListener('input', renderReportHistory);
document.getElementById('filter-client').addEventListener('input', renderReportHistory);
renderTables();
renderConsolidadoAndCharts();
renderReportHistory();
};
// If access overlay is not present (or in case of errors), try to start the app directly
if (!accessOverlay) {
initApp();
}
}
// Start the entire script logic
startCostingApp();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment