ya se pueden eliminar las filas de las plantillas

This commit is contained in:
unknown
2026-06-08 15:55:20 -04:00
parent b35cc2f54b
commit ff90340fdb
11 changed files with 632 additions and 170 deletions

View File

@@ -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),

View File

@@ -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);

View File

@@ -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 = {

View File

@@ -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 <p className="loading">Cargando planilla</p>;
@@ -65,8 +87,8 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
return (
<div>
<h1 className="page-title">{title}</h1>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
{loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
{!editable && <div className="alert alert-info">Solo lectura</div>}
<div className="toolbar">
@@ -75,8 +97,11 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
+
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Guardando…' : 'Guardar planilla'}
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
Guardar ahora
</button>
<button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla
</button>
</>
)}
@@ -122,7 +147,16 @@ export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
</td>
{editable && (
<td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeRow(i)}>
<button
type="button"
className="btn btn-danger btn-icon"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeRow(i);
}}
title="Eliminar fila"
>
×
</button>
</td>

View File

@@ -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 <p className="loading">Cargando planilla</p>;
@@ -64,8 +90,8 @@ export default function IngresoSheet({ sheetType, title }) {
return (
<div>
<h1 className="page-title">{title}</h1>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
{loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
{!editable && <div className="alert alert-info">Solo lectura sin permiso de edición</div>}
<div className="toolbar">
@@ -74,8 +100,11 @@ export default function IngresoSheet({ sheetType, title }) {
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
+
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Guardando…' : 'Guardar planilla'}
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
Guardar ahora
</button>
<button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla
</button>
</>
)}
@@ -123,7 +152,16 @@ export default function IngresoSheet({ sheetType, title }) {
<td className="computed">{formatMoney(utilidad)}</td>
{editable && (
<td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeRow(i)} title="Quitar fila">
<button
type="button"
className="btn btn-danger btn-icon"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeRow(i);
}}
title="Quitar fila"
>
×
</button>
</td>

View File

@@ -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 <p className="loading">Cargando planilla</p>;
return (
<div>
<h1 className="page-title">Planilla de Personal</h1>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
{loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
<div className="toolbar">
{editable && (
@@ -91,8 +129,16 @@ export default function PersonalSheet() {
>
+ Eventual
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
Guardar
<button
type="button"
className="btn btn-secondary"
onClick={() => saveNow({ mensualizados, eventuales })}
disabled={status === 'saving'}
>
Guardar ahora
</button>
<button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla
</button>
</>
)}
@@ -109,6 +155,7 @@ export default function PersonalSheet() {
<th>Cargas sociales</th>
<th>Bonos</th>
<th>Subtotal</th>
{editable && <th></th>}
</tr>
</thead>
<tbody>
@@ -127,6 +174,13 @@ export default function PersonalSheet() {
<input type="number" value={row.bonos ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
</td>
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
{editable && (
<td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeMensual(i)} title="Eliminar fila">
×
</button>
</td>
)}
</tr>
))}
</tbody>
@@ -143,6 +197,7 @@ export default function PersonalSheet() {
<th>Fecha</th>
<th>Nombre</th>
<th>Pago diario</th>
{editable && <th></th>}
</tr>
</thead>
<tbody>
@@ -157,6 +212,13 @@ export default function PersonalSheet() {
<td>
<input type="number" value={row.pagoDiario ?? ''} disabled={!editable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
</td>
{editable && (
<td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeEventual(i)} title="Eliminar fila">
×
</button>
</td>
)}
</tr>
))}
</tbody>

View File

@@ -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 (
<div className={`save-status save-status--${status}`}>
<span>{LABELS[status] || status}</span>
{isError && error && <span className="save-status__detail"> {error}</span>}
{!isError && status === 'pending' && (
<span className="save-status__detail"> (se guarda al cambiar de pestaña)</span>
)}
</div>
);
}

View File

@@ -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 };
}

View File

@@ -16,7 +16,7 @@ export default function HomePage() {
{user?.isAdmin && <li>Panel de Administración para gestionar usuarios y permisos</li>}
</ul>
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
Los datos se almacenan en el servidor de la red local. Use el botón <strong>+</strong> en cada planilla para agregar filas y <strong>Guardar</strong> para persistir.
Los datos se guardan automáticamente en el servidor al editar y al cambiar de pestaña. Use el botón <strong>+</strong> para agregar filas. Asegúrese de tener el backend en ejecución (puerto 4000).
</p>
</div>
);

View File

@@ -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 (
<div>
<h1 className="page-title">Planilla de Reportes</h1>
@@ -55,24 +73,31 @@ export default function ReportesPage() {
<label>
Tipo de reporte{' '}
<select value={tipo} onChange={(e) => setTipo(e.target.value)}>
<option value="todos">Todo (sin filtro de fecha)</option>
<option value="diario">Diario</option>
<option value="semanal">Semanal</option>
<option value="mensual">Mensual</option>
<option value="custom">Rango personalizado</option>
</select>
</label>
<label>
Desde <input type="date" value={desde} onChange={(e) => setDesde(e.target.value)} />
</label>
<label>
Hasta <input type="date" value={hasta} onChange={(e) => setHasta(e.target.value)} />
</label>
{tipo !== 'todos' && (
<>
<label>
Desde <input type="date" value={desde} onChange={(e) => setDesde(e.target.value)} />
</label>
<label>
Hasta <input type="date" value={hasta} onChange={(e) => setHasta(e.target.value)} />
</label>
</>
)}
<button type="button" className="btn btn-primary" onClick={loadReport} disabled={loading}>
{loading ? 'Calculando…' : 'Generar reporte'}
</button>
</div>
<p style={{ fontSize: '0.85rem', color: 'var(--muted)' }}>Planillas a incluir en el cálculo:</p>
<p style={{ fontSize: '0.85rem', color: 'var(--muted)' }}>
Planillas a incluir en el cálculo. Los registros <strong>sin fecha</strong> se suman igualmente para no perder datos.
</p>
<div className="perm-grid">
{INCLUDE_OPTS.map((o) => (
<label key={o.key} className="perm-item checkbox-row">
@@ -85,51 +110,85 @@ export default function ReportesPage() {
{error && <div className="alert alert-error">{error}</div>}
{loading && !report && <p className="loading">Generando reporte</p>}
{report && (
<>
<div className="alert alert-info">
<strong>Período:</strong> {report.periodoLabel || 'Sin definir'}
{report.periodo?.desde && (
<span> Desde {report.periodo.desde}{report.periodo.hasta ? ` hasta ${report.periodo.hasta}` : ''}</span>
)}
</div>
{isEmptyReport(report) && !hayDatosEnBd && (
<div className="alert alert-error">
No hay datos guardados en ninguna planilla. Cargue ventas, gastos o personal en las otras pestañas y espere el mensaje <em>Guardado automáticamente</em> antes de volver aquí.
</div>
)}
{isEmptyReport(report) && hayDatosEnBd && (
<div className="alert alert-info">
Hay registros en la base de datos pero el total del período es $0. Pruebe <strong>Todo (sin filtro de fecha)</strong> o ajuste las fechas en las planillas para que coincidan con el rango seleccionado.
</div>
)}
<div className="card">
<h2 style={{ marginTop: 0 }}>Desglose por planilla</h2>
<table className="sheet-table" style={{ minWidth: 'auto' }}>
<thead>
<tr>
<th>Planilla</th>
<th>Total</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ventas (ingresos)</td>
<td className="computed">{formatMoney(report.desglose.ventas.totalIngresos)}</td>
<td>Utilidad: {formatMoney(report.desglose.ventas.totalUtilidad)}</td>
<td>{report.desglose.ventas.filas} filas Utilidad: {formatMoney(report.desglose.ventas.totalUtilidad)}</td>
</tr>
<tr>
<td>Servicios</td>
<td className="computed">{formatMoney(report.desglose.servicios.totalIngresos)}</td>
<td>Utilidad: {formatMoney(report.desglose.servicios.totalUtilidad)}</td>
<td>{report.desglose.servicios.filas} filas Utilidad: {formatMoney(report.desglose.servicios.totalUtilidad)}</td>
</tr>
<tr>
<td>Alquileres</td>
<td className="computed">{formatMoney(report.desglose.alquileres.totalIngresos)}</td>
<td>Utilidad: {formatMoney(report.desglose.alquileres.totalUtilidad)}</td>
<td>{report.desglose.alquileres.filas} filas Utilidad: {formatMoney(report.desglose.alquileres.totalUtilidad)}</td>
</tr>
<tr>
<td>Gastos diarios</td>
<td colSpan={2} className="computed">{formatMoney(report.desglose.gastosDiarios.total)}</td>
<td className="computed">{formatMoney(report.desglose.gastosDiarios.total)}</td>
<td>{report.desglose.gastosDiarios.filas} filas</td>
</tr>
<tr>
<td>Gastos fijos</td>
<td colSpan={2} className="computed">{formatMoney(report.desglose.gastosFijos.total)}</td>
<td className="computed">{formatMoney(report.desglose.gastosFijos.total)}</td>
<td>{report.desglose.gastosFijos.filas} filas</td>
</tr>
<tr>
<td>Gastos variables (clasificados)</td>
<td colSpan={2} className="computed">{formatMoney(report.desglose.gastosVariables.total)}</td>
<td>Gastos variables</td>
<td className="computed">{formatMoney(report.desglose.gastosVariables.total)}</td>
<td>Clasificados como variable</td>
</tr>
<tr>
<td>Planilla de personal</td>
<td colSpan={2} className="computed">{formatMoney(report.desglose.personal.total)}</td>
<td className="computed">{formatMoney(report.desglose.personal.total)}</td>
<td>
{report.desglose.personal.mensualizados} mensualizados, {report.desglose.personal.eventuales} eventuales
</td>
</tr>
</tbody>
</table>
</div>
<div className="totals-bar" style={{ marginTop: '1rem' }}>
<span>Total ingresos: {formatMoney(report.totales.totalIngresos)}</span>
<span>Total gastos: {formatMoney(report.totales.totalGastos)}</span>
<span>Total personal: {formatMoney(report.totales.totalPersonal)}</span>
<span>Total ingresos: <strong>{formatMoney(report.totales.totalIngresos)}</strong></span>
<span>Total gastos: <strong>{formatMoney(report.totales.totalGastos)}</strong></span>
<span>Total personal: <strong>{formatMoney(report.totales.totalPersonal)}</strong></span>
</div>
{report.cierreSemanal && Object.keys(report.cierreSemanal).length > 0 && (
@@ -161,7 +220,7 @@ export default function ReportesPage() {
)}
<div className={`report-result ${report.totales.estado}`}>
Resultado neto ({tipo}): {formatMoney(report.totales.resultadoNeto)} {' '}
Resultado neto: {formatMoney(report.totales.resultadoNeto)} {' '}
{report.totales.estado === 'utilidad' ? 'UTILIDAD (Ganancia)' : 'PÉRDIDA'}
</div>

View File

@@ -347,6 +347,41 @@ textarea {
color: var(--muted);
}
.save-status {
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
margin-bottom: 0.75rem;
font-size: 0.85rem;
border: 1px solid var(--border);
}
.save-status--pending {
background: rgba(255, 176, 32, 0.12);
border-color: var(--warning);
color: #ffd080;
}
.save-status--saving {
background: rgba(61, 156, 245, 0.12);
border-color: var(--accent);
}
.save-status--saved {
background: rgba(52, 199, 89, 0.12);
border-color: var(--success);
color: #8ee4a8;
}
.save-status--error {
background: rgba(255, 92, 92, 0.15);
border-color: var(--danger);
color: #ffb0b0;
}
.save-status__detail {
opacity: 0.85;
}
@media (max-width: 768px) {
.navbar {
padding: 0.5rem;