469 lines
9.1 KiB
JavaScript
469 lines
9.1 KiB
JavaScript
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
|
||
import { sheetsApi } from '../api/client';
|
||
|
||
import { useAuth } from '../context/AuthContext';
|
||
|
||
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
||
|
||
import { useGestionView } from '../hooks/useGestionView';
|
||
|
||
import SaveStatus from './SaveStatus';
|
||
|
||
import GestionBanner from './GestionBanner';
|
||
|
||
import GestionesSidebar from './GestionesSidebar';
|
||
|
||
import { emptyGastoRow, formatMoney } from '../utils/calc';
|
||
|
||
import { isRowEditable } from '../utils/gestion';
|
||
|
||
|
||
|
||
export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
|
||
|
||
const { canEdit, user } = useAuth();
|
||
|
||
const editable = canEdit(sheetType);
|
||
|
||
const isAdmin = !!user?.isAdmin;
|
||
|
||
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||
|
||
const [rows, setRows] = useState([emptyGastoRow()]);
|
||
|
||
const [gestion, setGestion] = useState(null);
|
||
|
||
const [gestionVista, setGestionVista] = useState(null);
|
||
|
||
const [esHistorial, setEsHistorial] = useState(false);
|
||
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [loadError, setLoadError] = useState(null);
|
||
|
||
const skipNotifyRef = useRef(false);
|
||
|
||
|
||
|
||
const emptyRow = () => ({ ...emptyGastoRow(), clasificacion: defaultClasificacion });
|
||
|
||
const canEditSheet = editable && !esHistorial;
|
||
|
||
|
||
|
||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||
|
||
enabled: canEditSheet,
|
||
|
||
sheetType,
|
||
|
||
});
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
|
||
let active = true;
|
||
|
||
setLoading(true);
|
||
|
||
setLoadError(null);
|
||
|
||
setReady(false);
|
||
|
||
|
||
|
||
(async () => {
|
||
|
||
try {
|
||
|
||
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
|
||
|
||
if (!active) return;
|
||
|
||
const loaded = data.rows || [];
|
||
|
||
skipNotifyRef.current = true;
|
||
|
||
if (loaded.length) {
|
||
|
||
setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion })));
|
||
|
||
} else if (!data.esHistorial && canEditSheet) {
|
||
|
||
setRows([emptyRow()]);
|
||
|
||
} else {
|
||
|
||
setRows([]);
|
||
|
||
}
|
||
|
||
setGestion(data.gestion || null);
|
||
|
||
setGestionVista(data.gestionVista || null);
|
||
|
||
setEsHistorial(!!data.esHistorial);
|
||
|
||
setReady(!data.esHistorial);
|
||
|
||
} catch (e) {
|
||
|
||
if (active) setLoadError(e.message);
|
||
|
||
} finally {
|
||
|
||
if (active) setLoading(false);
|
||
|
||
}
|
||
|
||
})();
|
||
|
||
|
||
|
||
return () => {
|
||
|
||
active = false;
|
||
|
||
};
|
||
|
||
}, [sheetType, defaultClasificacion, selectedGestionId, setReady, canEditSheet]);
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
|
||
if (loading || !canEditSheet) return;
|
||
|
||
if (skipNotifyRef.current) {
|
||
|
||
skipNotifyRef.current = false;
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
notifyChange({ rows });
|
||
|
||
}, [rows, loading, canEditSheet, notifyChange]);
|
||
|
||
|
||
|
||
const total = useMemo(
|
||
|
||
() => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0),
|
||
|
||
[rows]
|
||
|
||
);
|
||
|
||
|
||
|
||
const updateRow = (index, field, value) => {
|
||
|
||
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||
|
||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||
|
||
};
|
||
|
||
|
||
|
||
const addRow = () => setRows((prev) => [...prev, emptyRow()]);
|
||
|
||
|
||
|
||
const removeRow = (index) => {
|
||
|
||
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||
|
||
skipNotifyRef.current = true;
|
||
|
||
const next = rows.filter((_, i) => i !== index);
|
||
|
||
const result = next.length > 0 ? next : [emptyRow()];
|
||
|
||
setRows(result);
|
||
|
||
saveNow({ rows: result });
|
||
|
||
};
|
||
|
||
|
||
|
||
const clearSheet = () => {
|
||
|
||
const msg = gestion?.fechaCierreGestion && !isAdmin
|
||
|
||
? '¿Eliminar solo las filas editables? Las del período cerrado se conservarán.'
|
||
|
||
: '¿Vaciar toda la planilla? Se eliminarán todas las filas de la gestión actual.';
|
||
|
||
if (!window.confirm(msg)) return;
|
||
|
||
skipNotifyRef.current = true;
|
||
|
||
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||
|
||
const blank = [emptyRow()];
|
||
|
||
setRows(blank);
|
||
|
||
saveNow({ rows: blank });
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
const kept = rows.filter((r) => r.bloqueada);
|
||
|
||
const result = kept.length ? kept : [emptyRow()];
|
||
|
||
setRows(result);
|
||
|
||
saveNow({ rows: result });
|
||
|
||
};
|
||
|
||
|
||
|
||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||
|
||
|
||
|
||
return (
|
||
|
||
<div className="sheet-layout">
|
||
|
||
<GestionesSidebar
|
||
|
||
gestiones={sidebarGestiones}
|
||
|
||
selectedId={selectedGestionId}
|
||
|
||
onSelect={selectGestion}
|
||
|
||
/>
|
||
|
||
|
||
|
||
<div className="sheet-layout__main">
|
||
|
||
<h1 className="page-title">{title}</h1>
|
||
|
||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||
|
||
|
||
|
||
{esHistorial && gestionVista && (
|
||
|
||
<div className="historial-badge">
|
||
|
||
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||
|
||
{gestionVista.fechaFin}) — solo lectura
|
||
|
||
</div>
|
||
|
||
)}
|
||
|
||
|
||
|
||
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
|
||
|
||
{canEditSheet && <SaveStatus status={status} error={saveError} />}
|
||
|
||
{esHistorial && <div className="alert alert-info">Gestión cerrada — solo consulta</div>}
|
||
|
||
{!editable && !esHistorial && <div className="alert alert-info">Solo lectura</div>}
|
||
|
||
|
||
|
||
<div className="toolbar">
|
||
|
||
{canEditSheet && (
|
||
|
||
<>
|
||
|
||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
|
||
|
||
+
|
||
|
||
</button>
|
||
|
||
<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>
|
||
|
||
</>
|
||
|
||
)}
|
||
|
||
</div>
|
||
|
||
|
||
|
||
{rows.length === 0 ? (
|
||
|
||
<p className="loading">Sin registros en esta gestión.</p>
|
||
|
||
) : (
|
||
|
||
<div className="card sheet-table-wrap">
|
||
|
||
<table className="sheet-table">
|
||
|
||
<thead>
|
||
|
||
<tr>
|
||
|
||
<th>Fecha</th>
|
||
|
||
<th>Detalle</th>
|
||
|
||
<th>Impuestos</th>
|
||
|
||
<th>Nº Factura</th>
|
||
|
||
<th>Monto</th>
|
||
|
||
<th>Clasificación</th>
|
||
|
||
{canEditSheet && <th></th>}
|
||
|
||
</tr>
|
||
|
||
</thead>
|
||
|
||
<tbody>
|
||
|
||
{rows.map((row, i) => {
|
||
|
||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||
|
||
return (
|
||
|
||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||
|
||
<td>
|
||
|
||
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
<input value={row.numeroFactura || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
||
|
||
</td>
|
||
|
||
<td>
|
||
|
||
<select value={row.clasificacion || 'diario'} disabled={!rowEditable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
|
||
|
||
<option value="diario">Gasto Diario</option>
|
||
|
||
<option value="fijo">Gasto Fijo</option>
|
||
|
||
<option value="variable">Gasto Variable</option>
|
||
|
||
</select>
|
||
|
||
</td>
|
||
|
||
{canEditSheet && (
|
||
|
||
<td>
|
||
|
||
<button
|
||
|
||
type="button"
|
||
|
||
className="btn btn-danger btn-icon"
|
||
|
||
onClick={(e) => {
|
||
|
||
e.preventDefault();
|
||
|
||
e.stopPropagation();
|
||
|
||
removeRow(i);
|
||
|
||
}}
|
||
|
||
disabled={!rowEditable}
|
||
|
||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||
|
||
>
|
||
|
||
×
|
||
|
||
</button>
|
||
|
||
</td>
|
||
|
||
)}
|
||
|
||
</tr>
|
||
|
||
);
|
||
|
||
})}
|
||
|
||
</tbody>
|
||
|
||
</table>
|
||
|
||
</div>
|
||
|
||
)}
|
||
|
||
|
||
|
||
<div className="totals-bar">
|
||
|
||
<span>
|
||
|
||
Total gastos: <strong>{formatMoney(total)}</strong>
|
||
|
||
</span>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
);
|
||
|
||
}
|
||
|