From ff90340fdb179d947f0f9477c1f7279ad435a8bf Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 8 Jun 2026 15:55:20 -0400 Subject: [PATCH] ya se pueden eliminar las filas de las plantillas --- backend/src/routes/reports.js | 155 +++++++++++++++------- backend/src/utils/calculations.js | 31 +++++ frontend/src/api/client.js | 29 +++- frontend/src/components/GastoSheet.jsx | 100 +++++++++----- frontend/src/components/IngresoSheet.jsx | 92 +++++++++---- frontend/src/components/PersonalSheet.jsx | 114 ++++++++++++---- frontend/src/components/SaveStatus.jsx | 23 ++++ frontend/src/hooks/useSheetAutoSave.js | 104 +++++++++++++++ frontend/src/pages/HomePage.jsx | 2 +- frontend/src/pages/ReportesPage.jsx | 117 ++++++++++++---- frontend/src/styles/global.css | 35 +++++ 11 files changed, 632 insertions(+), 170 deletions(-) create mode 100644 frontend/src/components/SaveStatus.jsx create mode 100644 frontend/src/hooks/useSheetAutoSave.js diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 3560bfa..be972a8 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -2,13 +2,13 @@ import { Router } from 'express'; import { db } from '../db.js'; import { authRequired } from '../middleware/auth.js'; import { - inRange, sumIngresos, sumGastos, sumPersonal, weekKey, monthKey, parseFecha, + filterRowsByDate, } from '../utils/calculations.js'; const router = Router(); @@ -21,8 +21,60 @@ function loadRows(sheetType) { .map((r) => JSON.parse(r.data)); } -function filterByDate(rows, fechaField, desde, hasta) { - return rows.filter((r) => inRange(r[fechaField], desde, hasta)); +function countSinFecha(rows, fechaField) { + return rows.filter((r) => !r[fechaField] || String(r[fechaField]).trim() === '').length; +} + +function resolvePeriod(tipo, desde, hasta) { + const today = new Date(); + const todayStr = today.toISOString().slice(0, 10); + let d = desde || null; + let h = hasta || null; + + if (tipo === 'todos') { + return { desde: null, hasta: null, label: 'Todo el historial (sin filtro de fecha)' }; + } + + if (tipo === 'diario') { + const f = h || d || todayStr; + return { desde: f, hasta: f, label: `Día ${f}` }; + } + + if (tipo === 'semanal') { + const ref = parseFecha(h || d || todayStr); + const day = ref.getDay(); + const diff = day === 0 ? -6 : 1 - day; + const monday = new Date(ref); + monday.setDate(ref.getDate() + diff); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + d = monday.toISOString().slice(0, 10); + h = sunday.toISOString().slice(0, 10); + return { desde: d, hasta: h, label: `Semana ${d} al ${h}` }; + } + + if (tipo === 'mensual' && !d && !h) { + d = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`; + const last = new Date(today.getFullYear(), today.getMonth() + 1, 0); + h = last.toISOString().slice(0, 10); + return { + desde: d, + hasta: h, + label: `Mes ${d.slice(0, 7)} (${d} al ${h})`, + }; + } + + if (d && h) { + return { desde: d, hasta: h, label: `Personalizado ${d} al ${h}` }; + } + if (d) { + return { desde: d, hasta: null, label: `Desde ${d}` }; + } + if (h) { + return { desde: null, hasta: h, label: `Hasta ${h}` }; + } + + return { desde: null, hasta: null, label: 'Sin filtro de fecha' }; } function buildReport(desde, hasta, include = {}) { @@ -35,20 +87,22 @@ function buildReport(desde, hasta, include = {}) { personal: include.personal !== false, }; - const ventas = inc.ventas ? filterByDate(loadRows('ventas'), 'fecha', desde, hasta) : []; - const servicios = inc.servicios ? filterByDate(loadRows('servicios'), 'fecha', desde, hasta) : []; - const alquileres = inc.alquileres ? filterByDate(loadRows('alquileres'), 'fecha', desde, hasta) : []; - const gastosDiarios = inc.gastos_diarios - ? filterByDate(loadRows('gastos_diarios'), 'fecha', desde, hasta) - : []; - const gastosFijos = inc.gastos_fijos - ? filterByDate(loadRows('gastos_fijos'), 'fecha', desde, hasta) - : []; + const ventasAll = inc.ventas ? loadRows('ventas') : []; + const serviciosAll = inc.servicios ? loadRows('servicios') : []; + const alquileresAll = inc.alquileres ? loadRows('alquileres') : []; + const gastosDiariosAll = inc.gastos_diarios ? loadRows('gastos_diarios') : []; + const gastosFijosAll = inc.gastos_fijos ? loadRows('gastos_fijos') : []; + + const ventas = filterRowsByDate(ventasAll, 'fecha', desde, hasta); + const servicios = filterRowsByDate(serviciosAll, 'fecha', desde, hasta); + const alquileres = filterRowsByDate(alquileresAll, 'fecha', desde, hasta); + const gastosDiarios = filterRowsByDate(gastosDiariosAll, 'fecha', desde, hasta); + const gastosFijos = filterRowsByDate(gastosFijosAll, 'fecha', desde, hasta); const personalRows = inc.personal ? loadRows('personal') : []; const mensualizados = personalRows.filter((r) => r.tipo === 'mensualizado'); const eventuales = inc.personal - ? filterByDate( + ? filterRowsByDate( personalRows.filter((r) => r.tipo === 'eventual'), 'fecha', desde, @@ -71,21 +125,40 @@ function buildReport(desde, hasta, include = {}) { ); const gastosVariablesTotal = sumGastos(gastosVariables); const totalGastos = gastosDiariosTotal + gastosFijosTotal; - const totalPersonal = sumPersonal(mensualizados, eventuales); - const resultadoNeto = totalIngresos - totalGastos - totalPersonal; return { - periodo: { desde: desde || null, hasta: hasta || null }, + periodo: { desde, hasta }, desglose: { - ventas: tVentas, - servicios: tServicios, - alquileres: tAlquileres, + ventas: { ...tVentas, filas: ventas.length }, + servicios: { ...tServicios, filas: servicios.length }, + alquileres: { ...tAlquileres, filas: alquileres.length }, gastosDiarios: { total: gastosDiariosTotal, filas: gastosDiarios.length }, gastosFijos: { total: gastosFijosTotal, filas: gastosFijos.length }, gastosVariables: { total: gastosVariablesTotal }, - personal: { total: totalPersonal, mensualizados: mensualizados.length, eventuales: eventuales.length }, + personal: { + total: totalPersonal, + mensualizados: mensualizados.length, + eventuales: eventuales.length, + }, + }, + meta: { + totalRegistros: { + ventas: ventasAll.length, + servicios: serviciosAll.length, + alquileres: alquileresAll.length, + gastos_diarios: gastosDiariosAll.length, + gastos_fijos: gastosFijosAll.length, + personal: personalRows.length, + }, + sinFecha: { + ventas: countSinFecha(ventasAll, 'fecha'), + servicios: countSinFecha(serviciosAll, 'fecha'), + alquileres: countSinFecha(alquileresAll, 'fecha'), + gastos_diarios: countSinFecha(gastosDiariosAll, 'fecha'), + gastos_fijos: countSinFecha(gastosFijosAll, 'fecha'), + }, }, totales: { totalIngresos, @@ -100,29 +173,6 @@ function buildReport(desde, hasta, include = {}) { router.get('/', (req, res) => { const { desde, hasta, tipo = 'mensual', include } = req.query; - let d = desde; - let h = hasta; - - const today = new Date(); - if (tipo === 'diario') { - const f = (h || d || today.toISOString().slice(0, 10)); - d = f; - h = f; - } else if (tipo === 'semanal') { - const ref = parseFecha(h || d || today.toISOString().slice(0, 10)); - const day = ref.getDay(); - const diff = day === 0 ? -6 : 1 - day; - const monday = new Date(ref); - monday.setDate(ref.getDate() + diff); - const sunday = new Date(monday); - sunday.setDate(monday.getDate() + 6); - d = monday.toISOString().slice(0, 10); - h = sunday.toISOString().slice(0, 10); - } else if (tipo === 'mensual' && !d && !h) { - d = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`; - const last = new Date(today.getFullYear(), today.getMonth() + 1, 0); - h = last.toISOString().slice(0, 10); - } let includeObj = {}; if (include) { @@ -133,14 +183,17 @@ router.get('/', (req, res) => { } } - const report = buildReport(d, h, includeObj); + const period = resolvePeriod(tipo, desde, hasta); + const report = buildReport(period.desde, period.hasta, includeObj); + report.periodoLabel = period.label; if (tipo === 'semanal' || tipo === 'mensual') { const gastosDiariosAll = loadRows('gastos_diarios'); + const gastosEnPeriodo = filterRowsByDate(gastosDiariosAll, 'fecha', period.desde, period.hasta); const weekly = {}; - for (const g of gastosDiariosAll) { - const wk = weekKey(g.fecha); - if (!inRange(g.fecha, d, h)) continue; + for (const g of gastosEnPeriodo) { + const ref = g.fecha || period.desde || period.hasta; + const wk = weekKey(ref); weekly[wk] = (weekly[wk] || 0) + (Number(g.monto) || 0); } report.cierreSemanal = weekly; @@ -148,12 +201,12 @@ router.get('/', (req, res) => { if (tipo === 'mensual') { const monthly = {}; - for (const g of gastosDiariosAll) { - const mk = monthKey(g.fecha); - if (!inRange(g.fecha, d, h)) continue; + for (const g of gastosEnPeriodo) { + const ref = g.fecha || period.desde || period.hasta; + const mk = monthKey(ref); monthly[mk] = (monthly[mk] || 0) + (Number(g.monto) || 0); } - const fijosAll = filterByDate(loadRows('gastos_fijos'), 'fecha', d, h); + const fijosAll = filterRowsByDate(loadRows('gastos_fijos'), 'fecha', period.desde, period.hasta); report.cierreMensual = { gastosDiariosPorMes: monthly, gastosFijosEnPeriodo: sumGastos(fijosAll), diff --git a/backend/src/utils/calculations.js b/backend/src/utils/calculations.js index b79f0fa..647ccfe 100644 --- a/backend/src/utils/calculations.js +++ b/backend/src/utils/calculations.js @@ -51,6 +51,37 @@ export function inRange(fecha, desde, hasta) { return true; } +/** Filas de ingreso con datos cargados (aunque falte la fecha) */ +export function hasIngresoData(row) { + return ( + (Number(row.cantidad) || 0) > 0 || + (Number(row.precioUnitario) || 0) > 0 || + (Number(row.costoUnitario) || 0) > 0 || + Boolean(row.cliente?.trim()) || + Boolean(row.detalle?.trim()) + ); +} + +/** Filas de gasto con datos cargados */ +export function hasGastoData(row) { + return (Number(row.monto) || 0) > 0 || Boolean(row.detalle?.trim()); +} + +/** + * Filtra por rango de fechas. Las filas SIN fecha pero con datos + * se incluyen siempre (evita reportes en cero cuando el usuario no completó la fecha). + */ +export function filterRowsByDate(rows, fechaField, desde, hasta) { + if (!desde && !hasta) return rows; + return rows.filter((row) => { + const fecha = row[fechaField]; + if (!fecha || String(fecha).trim() === '') { + return hasIngresoData(row) || hasGastoData(row); + } + return inRange(fecha, desde, hasta); + }); +} + /** Lunes de la semana ISO (lunes como inicio) */ export function weekKey(fecha) { const d = parseFecha(fecha); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 7932f0a..6d22b0a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -4,12 +4,27 @@ function getToken() { return localStorage.getItem('donmarco_token'); } -export async function api(path, options = {}) { - const headers = { 'Content-Type': 'application/json', ...options.headers }; +function buildHeaders(extra = {}) { + const headers = { 'Content-Type': 'application/json', ...extra }; const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} - const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); +function friendlyError(err, fallback) { + if (err?.message === 'Failed to fetch' || err?.name === 'TypeError') { + return 'No se pudo conectar con el servidor. Verifique que el backend esté en ejecución (puerto 4000).'; + } + return fallback; +} + +export async function api(path, options = {}) { + let res; + try { + res = await fetch(`${API_BASE}${path}`, { ...options, headers: buildHeaders(options.headers) }); + } catch (err) { + throw new Error(friendlyError(err, 'Error de red')); + } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Error en la solicitud'); return data; @@ -32,6 +47,14 @@ export const sheetsApi = { get: (sheet) => api(`/sheets/${sheet}`), save: (sheet, body) => api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }), + /** Guardado al salir de la planilla (navegación entre pestañas) */ + saveKeepalive: (sheet, body) => + fetch(`${API_BASE}/sheets/${sheet}`, { + method: 'PUT', + headers: buildHeaders(), + body: JSON.stringify(body), + keepalive: true, + }), }; export const reportsApi = { diff --git a/frontend/src/components/GastoSheet.jsx b/frontend/src/components/GastoSheet.jsx index cf8a666..a5f14c1 100644 --- a/frontend/src/components/GastoSheet.jsx +++ b/frontend/src/components/GastoSheet.jsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { sheetsApi } from '../api/client'; import { useAuth } from '../context/AuthContext'; +import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; +import SaveStatus from './SaveStatus'; import { emptyGastoRow, formatMoney } from '../utils/calc'; export default function GastoSheet({ sheetType, title, defaultClasificacion }) { @@ -8,28 +10,48 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) { const editable = canEdit(sheetType); const [rows, setRows] = useState([emptyGastoRow()]); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ + enabled: editable, + sheetType, + }); useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + (async () => { try { const data = await sheetsApi.get(sheetType); + if (!active) return; const loaded = data.rows?.length ? data.rows : [emptyGastoRow()]; - setRows( - loaded.map((r) => ({ - ...r, - clasificacion: r.clasificacion || defaultClasificacion, - })) - ); + skipNotifyRef.current = true; + setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion }))); + setReady(true); } catch (e) { - setError(e.message); + if (active) setLoadError(e.message); } finally { - setLoading(false); + if (active) setLoading(false); } })(); - }, [sheetType, defaultClasificacion]); + + return () => { + active = false; + }; + }, [sheetType, defaultClasificacion, setReady]); + + useEffect(() => { + if (loading || !editable) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ rows }); + }, [rows, loading, editable, notifyChange]); const total = useMemo( () => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0), @@ -42,22 +64,22 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) { const addRow = () => setRows((prev) => [...prev, { ...emptyGastoRow(), clasificacion: defaultClasificacion }]); - const removeRow = (index) => setRows((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); - const handleSave = async () => { - if (!editable) return; - setSaving(true); - setMessage(null); - setError(null); - try { - const data = await sheetsApi.save(sheetType, { rows }); - setRows(data.rows?.length ? data.rows : [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }]); - setMessage('Planilla guardada correctamente'); - } catch (e) { - setError(e.message); - } finally { - setSaving(false); - } + const removeRow = (index) => { + skipNotifyRef.current = true; + const next = rows.filter((_, i) => i !== index); + const result = + next.length > 0 ? next : [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }]; + setRows(result); + saveNow({ rows: result }); + }; + + const clearSheet = () => { + if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return; + const blank = [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }]; + skipNotifyRef.current = true; + setRows(blank); + saveNow({ rows: blank }); }; if (loading) return

Cargando planilla…

; @@ -65,8 +87,8 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) { return (

{title}

- {error &&
{error}
} - {message &&
{message}
} + {loadError &&
{loadError}
} + {editable && } {!editable &&
Solo lectura
}
@@ -75,8 +97,11 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) { - + )} @@ -122,7 +147,16 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) { {editable && ( - diff --git a/frontend/src/components/IngresoSheet.jsx b/frontend/src/components/IngresoSheet.jsx index c85ee6b..7007d7e 100644 --- a/frontend/src/components/IngresoSheet.jsx +++ b/frontend/src/components/IngresoSheet.jsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { sheetsApi } from '../api/client'; import { useAuth } from '../context/AuthContext'; +import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; +import SaveStatus from './SaveStatus'; import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc'; export default function IngresoSheet({ sheetType, title }) { @@ -8,22 +10,47 @@ export default function IngresoSheet({ sheetType, title }) { const editable = canEdit(sheetType); const [rows, setRows] = useState([emptyIngresoRow()]); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ + enabled: editable, + sheetType, + }); useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + (async () => { try { const data = await sheetsApi.get(sheetType); + if (!active) return; + skipNotifyRef.current = true; setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]); + setReady(true); } catch (e) { - setError(e.message); + if (active) setLoadError(e.message); } finally { - setLoading(false); + if (active) setLoading(false); } })(); - }, [sheetType]); + + return () => { + active = false; + }; + }, [sheetType, setReady]); + + useEffect(() => { + if (loading || !editable) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ rows }); + }, [rows, loading, editable, notifyChange]); const totals = useMemo(() => { let totalIngresos = 0; @@ -41,22 +68,21 @@ export default function IngresoSheet({ sheetType, title }) { }; const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]); - const removeRow = (index) => setRows((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); - const handleSave = async () => { - if (!editable) return; - setSaving(true); - setMessage(null); - setError(null); - try { - const data = await sheetsApi.save(sheetType, { rows }); - setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]); - setMessage('Planilla guardada correctamente'); - } catch (e) { - setError(e.message); - } finally { - setSaving(false); - } + const removeRow = (index) => { + skipNotifyRef.current = true; + const next = rows.filter((_, i) => i !== index); + const result = next.length > 0 ? next : [emptyIngresoRow()]; + setRows(result); + saveNow({ rows: result }); + }; + + const clearSheet = () => { + if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return; + const blank = [emptyIngresoRow()]; + skipNotifyRef.current = true; + setRows(blank); + saveNow({ rows: blank }); }; if (loading) return

Cargando planilla…

; @@ -64,8 +90,8 @@ export default function IngresoSheet({ sheetType, title }) { return (

{title}

- {error &&
{error}
} - {message &&
{message}
} + {loadError &&
{loadError}
} + {editable && } {!editable &&
Solo lectura — sin permiso de edición
}
@@ -74,8 +100,11 @@ export default function IngresoSheet({ sheetType, title }) { - + )} @@ -123,7 +152,16 @@ export default function IngresoSheet({ sheetType, title }) { {formatMoney(utilidad)} {editable && ( - diff --git a/frontend/src/components/PersonalSheet.jsx b/frontend/src/components/PersonalSheet.jsx index 557b2da..704e698 100644 --- a/frontend/src/components/PersonalSheet.jsx +++ b/frontend/src/components/PersonalSheet.jsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { sheetsApi } from '../api/client'; import { useAuth } from '../context/AuthContext'; +import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; +import SaveStatus from './SaveStatus'; import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc'; const MAX_MENSUAL = 10; @@ -16,25 +18,50 @@ export default function PersonalSheet() { const [mensualizados, setMensualizados] = useState([emptyMensualizado()]); const [eventuales, setEventuales] = useState([emptyEventual()]); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ + enabled: editable, + sheetType: 'personal', + }); useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + (async () => { try { const data = await sheetsApi.get('personal'); + if (!active) return; + skipNotifyRef.current = true; setMensualizados( data.mensualizados?.length ? data.mensualizados.slice(0, MAX_MENSUAL) : [emptyMensualizado()] ); setEventuales(data.eventuales?.length ? data.eventuales : [emptyEventual()]); + setReady(true); } catch (e) { - setError(e.message); + if (active) setLoadError(e.message); } finally { - setLoading(false); + if (active) setLoading(false); } })(); - }, []); + + return () => { + active = false; + }; + }, [setReady]); + + useEffect(() => { + if (loading || !editable) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ mensualizados, eventuales }); + }, [mensualizados, eventuales, loading, editable, notifyChange]); const total = useMemo(() => { const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0); @@ -42,33 +69,44 @@ export default function PersonalSheet() { return m + e; }, [mensualizados, eventuales]); - const handleSave = async () => { - if (!editable) return; - setSaving(true); - setMessage(null); - setError(null); - try { - await sheetsApi.save('personal', { mensualizados, eventuales }); - setMessage('Planilla de personal guardada'); - } catch (e) { - setError(e.message); - } finally { - setSaving(false); - } - }; - const updateMensual = (i, field, value) => setMensualizados((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r))); const updateEventual = (i, field, value) => setEventuales((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r))); + const removeMensual = (index) => { + skipNotifyRef.current = true; + const next = mensualizados.filter((_, i) => i !== index); + const result = next.length > 0 ? next : [emptyMensualizado()]; + setMensualizados(result); + saveNow({ mensualizados: result, eventuales }); + }; + + const removeEventual = (index) => { + skipNotifyRef.current = true; + const next = eventuales.filter((_, i) => i !== index); + const result = next.length > 0 ? next : [emptyEventual()]; + setEventuales(result); + saveNow({ mensualizados, eventuales: result }); + }; + + const clearSheet = () => { + if (!window.confirm('¿Vaciar toda la planilla de personal?')) return; + const m = [emptyMensualizado()]; + const e = [emptyEventual()]; + skipNotifyRef.current = true; + setMensualizados(m); + setEventuales(e); + saveNow({ mensualizados: m, eventuales: e }); + }; + if (loading) return

Cargando planilla…

; return (

Planilla de Personal

- {error &&
{error}
} - {message &&
{message}
} + {loadError &&
{loadError}
} + {editable && }
{editable && ( @@ -91,8 +129,16 @@ export default function PersonalSheet() { > + Eventual - + )} @@ -109,6 +155,7 @@ export default function PersonalSheet() { Cargas sociales Bonos Subtotal + {editable && } @@ -127,6 +174,13 @@ export default function PersonalSheet() { updateMensual(i, 'bonos', e.target.value)} /> {formatMoney(rowCostMensual(row))} + {editable && ( + + + + )} ))} @@ -143,6 +197,7 @@ export default function PersonalSheet() { Fecha Nombre Pago diario + {editable && } @@ -157,6 +212,13 @@ export default function PersonalSheet() { updateEventual(i, 'pagoDiario', e.target.value)} /> + {editable && ( + + + + )} ))} diff --git a/frontend/src/components/SaveStatus.jsx b/frontend/src/components/SaveStatus.jsx new file mode 100644 index 0000000..ce60c91 --- /dev/null +++ b/frontend/src/components/SaveStatus.jsx @@ -0,0 +1,23 @@ +const LABELS = { + idle: 'Listo', + pending: 'Cambios sin guardar…', + saving: 'Guardando…', + saved: 'Guardado automáticamente', + error: 'Error al guardar', +}; + +export default function SaveStatus({ status, error }) { + if (status === 'idle') return null; + + const isError = status === 'error'; + + return ( +
+ {LABELS[status] || status} + {isError && error && — {error}} + {!isError && status === 'pending' && ( + (se guarda al cambiar de pestaña) + )} +
+ ); +} diff --git a/frontend/src/hooks/useSheetAutoSave.js b/frontend/src/hooks/useSheetAutoSave.js new file mode 100644 index 0000000..337976a --- /dev/null +++ b/frontend/src/hooks/useSheetAutoSave.js @@ -0,0 +1,104 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { sheetsApi } from '../api/client'; + +/** + * Guarda automáticamente la planilla al editar (con debounce) + * y al salir de la pestaña (unmount / cambio de ruta). + */ +export function useSheetAutoSave({ enabled, sheetType, debounceMs = 700 }) { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const readyRef = useRef(false); + const dirtyRef = useRef(false); + const payloadRef = useRef(null); + const timerRef = useRef(null); + const savingRef = useRef(false); + const pendingPayloadRef = useRef(null); + + const runPersist = useCallback( + async (payload, { keepalive = false } = {}) => { + if (!enabled || !payload) return; + + if (savingRef.current && !keepalive) { + pendingPayloadRef.current = payload; + return; + } + + savingRef.current = true; + if (!keepalive) setStatus('saving'); + setError(null); + + try { + if (keepalive) { + await sheetsApi.saveKeepalive(sheetType, payload); + } else { + await sheetsApi.save(sheetType, payload); + } + dirtyRef.current = false; + if (!keepalive) setStatus('saved'); + } catch (e) { + dirtyRef.current = true; + setStatus('error'); + setError(e.message); + } finally { + savingRef.current = false; + const pending = pendingPayloadRef.current; + if (pending) { + pendingPayloadRef.current = null; + runPersist(pending); + } + } + }, + [enabled, sheetType] + ); + + const setReady = useCallback((value = true) => { + readyRef.current = value; + if (value) { + dirtyRef.current = false; + setStatus('idle'); + } + }, []); + + const notifyChange = useCallback( + (payload) => { + if (!enabled || !readyRef.current) return; + payloadRef.current = payload; + dirtyRef.current = true; + setStatus('pending'); + setError(null); + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + if (dirtyRef.current && payloadRef.current) { + runPersist(payloadRef.current); + } + }, debounceMs); + }, + [enabled, runPersist, debounceMs] + ); + + const saveNow = useCallback( + async (payload) => { + if (timerRef.current) clearTimeout(timerRef.current); + const data = payload || payloadRef.current; + if (data) { + payloadRef.current = data; + dirtyRef.current = true; + await runPersist(data); + } + }, + [runPersist] + ); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (enabled && dirtyRef.current && payloadRef.current) { + sheetsApi.saveKeepalive(sheetType, payloadRef.current); + } + }; + }, [enabled, sheetType]); + + return { status, error, setReady, notifyChange, saveNow }; +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index a413f53..10da21b 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -16,7 +16,7 @@ export default function HomePage() { {user?.isAdmin &&
  • Panel de Administración para gestionar usuarios y permisos
  • }

    - Los datos se almacenan en el servidor de la red local. Use el botón + en cada planilla para agregar filas y Guardar para persistir. + Los datos se guardan automáticamente en el servidor al editar y al cambiar de pestaña. Use el botón + para agregar filas. Asegúrese de tener el backend en ejecución (puerto 4000).

    ); diff --git a/frontend/src/pages/ReportesPage.jsx b/frontend/src/pages/ReportesPage.jsx index 0f1bed4..a2a8f78 100644 --- a/frontend/src/pages/ReportesPage.jsx +++ b/frontend/src/pages/ReportesPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { reportsApi } from '../api/client'; import { formatMoney } from '../utils/calc'; @@ -11,41 +11,59 @@ const INCLUDE_OPTS = [ { key: 'personal', label: 'Personal' }, ]; +function isEmptyReport(report) { + if (!report?.totales) return true; + const t = report.totales; + return ( + t.totalIngresos === 0 && + t.totalGastos === 0 && + t.totalPersonal === 0 + ); +} + export default function ReportesPage() { - const [tipo, setTipo] = useState('mensual'); + const [tipo, setTipo] = useState('todos'); const [desde, setDesde] = useState(''); const [hasta, setHasta] = useState(''); const [include, setInclude] = useState( Object.fromEntries(INCLUDE_OPTS.map((o) => [o.key, true])) ); const [report, setReport] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const loadReport = async () => { + const loadReport = useCallback(async () => { setLoading(true); setError(null); try { - const data = await reportsApi.get({ + const params = { tipo, - desde: desde || undefined, - hasta: hasta || undefined, include: JSON.stringify(include), - }); + }; + if (tipo !== 'todos') { + if (desde) params.desde = desde; + if (hasta) params.hasta = hasta; + } + const data = await reportsApi.get(params); setReport(data); } catch (e) { + setReport(null); setError(e.message); } finally { setLoading(false); } - }; + }, [tipo, desde, hasta, include]); useEffect(() => { loadReport(); - }, []); + }, [loadReport]); const toggleInclude = (key) => setInclude((p) => ({ ...p, [key]: !p[key] })); + const hayDatosEnBd = + report?.meta?.totalRegistros && + Object.values(report.meta.totalRegistros).some((n) => n > 0); + return (

    Planilla de Reportes

    @@ -55,24 +73,31 @@ export default function ReportesPage() { - - + {tipo !== 'todos' && ( + <> + + + + )}
    -

    Planillas a incluir en el cálculo:

    +

    + Planillas a incluir en el cálculo. Los registros sin fecha se suman igualmente para no perder datos. +

    {INCLUDE_OPTS.map((o) => (