ya se pueden eliminar las filas de las plantillas
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
23
frontend/src/components/SaveStatus.jsx
Normal file
23
frontend/src/components/SaveStatus.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/src/hooks/useSheetAutoSave.js
Normal file
104
frontend/src/hooks/useSheetAutoSave.js
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user