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 }) {
-