Created
June 24, 2025 00:34
-
-
Save memogs/7ee03614436ca3551be6d51ce291ecf2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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">×</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">×</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