no se que modificaciones se hizo posible error
This commit is contained in:
@@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
ARG APP_VERSION=1.1.0
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_APP_VERSION=$APP_VERSION
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function App() {
|
||||
<Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} />
|
||||
<Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||
<Route path="/cierre-gestion" element={<Navigate to="/admin#cierre-gestion" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,12 @@ export const usersApi = {
|
||||
};
|
||||
|
||||
export const sheetsApi = {
|
||||
get: (sheet) => api(`/sheets/${sheet}`),
|
||||
get: (sheet, params = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.gestionId) q.set('gestionId', params.gestionId);
|
||||
const qs = q.toString();
|
||||
return api(`/sheets/${sheet}${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
save: (sheet, body) =>
|
||||
api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
/** Guardado al salir de la planilla (navegación entre pestañas) */
|
||||
@@ -63,3 +68,16 @@ export const reportsApi = {
|
||||
return api(`/reports?${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
getGestion: () => api('/settings/gestion'),
|
||||
cerrarGestion: (fechaCierreGestion) =>
|
||||
api('/settings/gestion', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ fechaCierreGestion }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const gestionesApi = {
|
||||
list: () => api('/gestiones'),
|
||||
};
|
||||
|
||||
@@ -1,177 +1,468 @@
|
||||
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 } = useAuth();
|
||||
|
||||
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: editable,
|
||||
|
||||
enabled: canEditSheet,
|
||||
|
||||
sheetType,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
let active = true;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
setLoadError(null);
|
||||
|
||||
setReady(false);
|
||||
|
||||
|
||||
|
||||
(async () => {
|
||||
|
||||
try {
|
||||
const data = await sheetsApi.get(sheetType);
|
||||
|
||||
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
|
||||
|
||||
if (!active) return;
|
||||
const loaded = data.rows?.length ? data.rows : [emptyGastoRow()];
|
||||
|
||||
const loaded = data.rows || [];
|
||||
|
||||
skipNotifyRef.current = true;
|
||||
setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion })));
|
||||
setReady(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, setReady]);
|
||||
|
||||
}, [sheetType, defaultClasificacion, selectedGestionId, setReady, canEditSheet]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !editable) return;
|
||||
|
||||
if (loading || !canEditSheet) return;
|
||||
|
||||
if (skipNotifyRef.current) {
|
||||
|
||||
skipNotifyRef.current = false;
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
notifyChange({ rows });
|
||||
}, [rows, loading, editable, notifyChange]);
|
||||
|
||||
}, [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, { ...emptyGastoRow(), clasificacion: defaultClasificacion }]);
|
||||
|
||||
|
||||
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 : [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }];
|
||||
|
||||
const result = next.length > 0 ? next : [emptyRow()];
|
||||
|
||||
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 }];
|
||||
|
||||
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;
|
||||
setRows(blank);
|
||||
saveNow({ rows: blank });
|
||||
|
||||
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>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{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">
|
||||
{editable && (
|
||||
<>
|
||||
<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 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 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>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.numeroFactura || ''} disabled={!editable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={row.clasificacion || 'diario'} disabled={!editable} 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>
|
||||
{editable && (
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRow(i);
|
||||
}}
|
||||
title="Eliminar fila"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="totals-bar">
|
||||
<span>
|
||||
Total gastos: <strong>{formatMoney(total)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
14
frontend/src/components/GestionBanner.jsx
Normal file
14
frontend/src/components/GestionBanner.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { gestionMensaje } from '../utils/gestion';
|
||||
|
||||
export default function GestionBanner({ gestion, isAdmin }) {
|
||||
const msg = gestionMensaje(gestion, isAdmin);
|
||||
if (!msg) return null;
|
||||
|
||||
const activo = gestion?.gestionCerrada;
|
||||
|
||||
return (
|
||||
<div className={`alert ${activo ? 'alert-error' : 'alert-info'}`} style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>{activo ? 'Cierre de gestión activo' : 'Período en curso'}:</strong> {msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/GestionesSidebar.jsx
Normal file
49
frontend/src/components/GestionesSidebar.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export default function GestionesSidebar({ gestiones, gestionActual, selectedId, onSelect }) {
|
||||
const historial = gestiones?.historial || [];
|
||||
const actual = gestiones?.gestionActual || gestionActual;
|
||||
|
||||
return (
|
||||
<aside className="gestiones-sidebar">
|
||||
<h3 className="gestiones-sidebar__title">Gestiones</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`gestion-item gestion-item--actual${selectedId === 'actual' ? ' gestion-item--active' : ''}`}
|
||||
onClick={() => onSelect('actual')}
|
||||
>
|
||||
<span className="gestion-item__label">Gestión actual</span>
|
||||
{actual && (
|
||||
<span className="gestion-item__range">
|
||||
desde {actual.fechaInicio}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{historial.length > 0 && (
|
||||
<>
|
||||
<p className="gestiones-sidebar__subtitle">Historial</p>
|
||||
<ul className="gestion-list">
|
||||
{historial.map((g) => (
|
||||
<li key={g.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`gestion-item${String(selectedId) === String(g.id) ? ' gestion-item--active' : ''}`}
|
||||
onClick={() => onSelect(String(g.id))}
|
||||
>
|
||||
<span className="gestion-item__label">{g.etiqueta}</span>
|
||||
<span className="gestion-item__range">
|
||||
{g.fechaInicio} → {g.fechaFin}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{historial.length === 0 && (
|
||||
<p className="gestiones-sidebar__empty">Sin gestiones cerradas aún.</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +1,490 @@
|
||||
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 { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc';
|
||||
|
||||
import { isRowEditable } from '../utils/gestion';
|
||||
|
||||
|
||||
|
||||
export default function IngresoSheet({ sheetType, title }) {
|
||||
const { canEdit } = useAuth();
|
||||
|
||||
const { canEdit, user } = useAuth();
|
||||
|
||||
const editable = canEdit(sheetType);
|
||||
|
||||
const isAdmin = !!user?.isAdmin;
|
||||
|
||||
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||||
|
||||
const [rows, setRows] = useState([emptyIngresoRow()]);
|
||||
|
||||
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 canEditSheet = editable && !esHistorial;
|
||||
|
||||
|
||||
|
||||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||||
enabled: editable,
|
||||
|
||||
enabled: canEditSheet,
|
||||
|
||||
sheetType,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
let active = true;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
setLoadError(null);
|
||||
|
||||
setReady(false);
|
||||
|
||||
|
||||
|
||||
(async () => {
|
||||
|
||||
try {
|
||||
const data = await sheetsApi.get(sheetType);
|
||||
|
||||
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
|
||||
|
||||
if (!active) return;
|
||||
|
||||
skipNotifyRef.current = true;
|
||||
setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]);
|
||||
setReady(true);
|
||||
|
||||
const loaded = data.rows || [];
|
||||
|
||||
if (loaded.length) {
|
||||
|
||||
setRows(loaded);
|
||||
|
||||
} else if (!data.esHistorial && canEditSheet) {
|
||||
|
||||
setRows([emptyIngresoRow()]);
|
||||
|
||||
} 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, setReady]);
|
||||
|
||||
}, [sheetType, selectedGestionId, setReady, canEditSheet]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !editable) return;
|
||||
|
||||
if (loading || !canEditSheet) return;
|
||||
|
||||
if (skipNotifyRef.current) {
|
||||
|
||||
skipNotifyRef.current = false;
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
notifyChange({ rows });
|
||||
}, [rows, loading, editable, notifyChange]);
|
||||
|
||||
}, [rows, loading, canEditSheet, notifyChange]);
|
||||
|
||||
|
||||
|
||||
const totals = useMemo(() => {
|
||||
|
||||
let totalIngresos = 0;
|
||||
|
||||
let totalUtilidad = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
|
||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||
|
||||
totalIngresos += precioTotal;
|
||||
|
||||
totalUtilidad += utilidad;
|
||||
|
||||
});
|
||||
|
||||
return { totalIngresos, totalUtilidad };
|
||||
|
||||
}, [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, emptyIngresoRow()]);
|
||||
|
||||
|
||||
|
||||
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 : [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()];
|
||||
|
||||
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;
|
||||
setRows(blank);
|
||||
saveNow({ rows: blank });
|
||||
|
||||
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||||
|
||||
const blank = [emptyIngresoRow()];
|
||||
|
||||
setRows(blank);
|
||||
|
||||
saveNow({ rows: blank });
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const kept = rows.filter((r) => r.bloqueada);
|
||||
|
||||
const result = kept.length ? kept : [emptyIngresoRow()];
|
||||
|
||||
setRows(result);
|
||||
|
||||
saveNow({ rows: result });
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{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">
|
||||
{editable && (
|
||||
<>
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
||||
+
|
||||
</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 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>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Cliente</th>
|
||||
<th>Detalle</th>
|
||||
<th>Cantidad</th>
|
||||
<th>P. Unitario</th>
|
||||
<th>C. Unitario</th>
|
||||
<th>Precio Total</th>
|
||||
<th>Utilidad</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.cliente || ''} disabled={!editable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
||||
</td>
|
||||
<td className="computed">{formatMoney(precioTotal)}</td>
|
||||
<td className="computed">{formatMoney(utilidad)}</td>
|
||||
{editable && (
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRow(i);
|
||||
}}
|
||||
title="Quitar fila"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
|
||||
|
||||
{!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 — sin permiso de edición</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="toolbar">
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<>
|
||||
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
||||
|
||||
+
|
||||
|
||||
</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>Cliente</th>
|
||||
|
||||
<th>Detalle</th>
|
||||
|
||||
<th>Cantidad</th>
|
||||
|
||||
<th>P. Unitario</th>
|
||||
|
||||
<th>C. Unitario</th>
|
||||
|
||||
<th>Precio Total</th>
|
||||
|
||||
<th>Utilidad</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{rows.map((row, i) => {
|
||||
|
||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||
|
||||
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.cliente || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cliente', 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.cantidad ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td className="computed">{formatMoney(precioTotal)}</td>
|
||||
|
||||
<td className="computed">{formatMoney(utilidad)}</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' : 'Quitar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="totals-bar">
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="totals-bar">
|
||||
<span>
|
||||
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export default function Layout({ children }) {
|
||||
</NavLink>
|
||||
))}
|
||||
{user?.isAdmin && (
|
||||
<NavLink to="/admin" className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}>
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => `nav-tab nav-tab--admin${isActive ? ' active' : ''}`}
|
||||
>
|
||||
Administración
|
||||
</NavLink>
|
||||
)}
|
||||
@@ -46,7 +49,12 @@ export default function Layout({ children }) {
|
||||
Salir
|
||||
</button>
|
||||
</nav>
|
||||
<main className="main-content">{children}</main>
|
||||
<main className="main-content">
|
||||
{children}
|
||||
<p className="app-version">
|
||||
Don Marco v{import.meta.env.VITE_APP_VERSION || '1.2.0'} — Historial de gestiones (Docker)
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,236 +1,662 @@
|
||||
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 { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc';
|
||||
|
||||
import { isRowEditable } from '../utils/gestion';
|
||||
|
||||
|
||||
|
||||
const MAX_MENSUAL = 10;
|
||||
|
||||
const MAX_EVENTUAL = 5;
|
||||
|
||||
|
||||
|
||||
function rowCostMensual(r) {
|
||||
|
||||
return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function PersonalSheet() {
|
||||
const { canEdit } = useAuth();
|
||||
|
||||
const { canEdit, user } = useAuth();
|
||||
|
||||
const editable = canEdit('personal');
|
||||
|
||||
const isAdmin = !!user?.isAdmin;
|
||||
|
||||
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||||
|
||||
const [mensualizados, setMensualizados] = useState([emptyMensualizado()]);
|
||||
|
||||
const [eventuales, setEventuales] = useState([emptyEventual()]);
|
||||
|
||||
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 canEditSheet = editable && !esHistorial;
|
||||
|
||||
|
||||
|
||||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||||
enabled: editable,
|
||||
|
||||
enabled: canEditSheet,
|
||||
|
||||
sheetType: 'personal',
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
let active = true;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
setLoadError(null);
|
||||
|
||||
setReady(false);
|
||||
|
||||
|
||||
|
||||
(async () => {
|
||||
|
||||
try {
|
||||
const data = await sheetsApi.get('personal');
|
||||
|
||||
const data = await sheetsApi.get('personal', { gestionId: selectedGestionId });
|
||||
|
||||
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) {
|
||||
if (active) setLoadError(e.message);
|
||||
|
||||
const m = data.mensualizados || [];
|
||||
|
||||
const e = data.eventuales || [];
|
||||
|
||||
if (m.length || e.length) {
|
||||
|
||||
setMensualizados(m.length ? m.slice(0, MAX_MENSUAL) : []);
|
||||
|
||||
setEventuales(e);
|
||||
|
||||
} else if (!data.esHistorial && canEditSheet) {
|
||||
|
||||
setMensualizados([emptyMensualizado()]);
|
||||
|
||||
setEventuales([emptyEventual()]);
|
||||
|
||||
} else {
|
||||
|
||||
setMensualizados([]);
|
||||
|
||||
setEventuales([]);
|
||||
|
||||
}
|
||||
|
||||
setGestion(data.gestion || null);
|
||||
|
||||
setGestionVista(data.gestionVista || null);
|
||||
|
||||
setEsHistorial(!!data.esHistorial);
|
||||
|
||||
setReady(!data.esHistorial);
|
||||
|
||||
} catch (err) {
|
||||
|
||||
if (active) setLoadError(err.message);
|
||||
|
||||
} finally {
|
||||
|
||||
if (active) setLoading(false);
|
||||
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
|
||||
|
||||
return () => {
|
||||
|
||||
active = false;
|
||||
|
||||
};
|
||||
}, [setReady]);
|
||||
|
||||
}, [selectedGestionId, setReady, canEditSheet]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !editable) return;
|
||||
|
||||
if (loading || !canEditSheet) return;
|
||||
|
||||
if (skipNotifyRef.current) {
|
||||
|
||||
skipNotifyRef.current = false;
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
notifyChange({ mensualizados, eventuales });
|
||||
}, [mensualizados, eventuales, loading, editable, notifyChange]);
|
||||
|
||||
}, [mensualizados, eventuales, loading, canEditSheet, notifyChange]);
|
||||
|
||||
|
||||
|
||||
const total = useMemo(() => {
|
||||
|
||||
const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0);
|
||||
|
||||
const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0);
|
||||
|
||||
return m + e;
|
||||
|
||||
}, [mensualizados, eventuales]);
|
||||
|
||||
const updateMensual = (i, field, value) =>
|
||||
|
||||
|
||||
const updateMensual = (i, field, value) => {
|
||||
|
||||
if (!isRowEditable(mensualizados[i], canEditSheet, isAdmin)) return;
|
||||
|
||||
setMensualizados((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r)));
|
||||
const updateEventual = (i, field, value) =>
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const updateEventual = (i, field, value) => {
|
||||
|
||||
if (!isRowEditable(eventuales[i], canEditSheet, isAdmin)) return;
|
||||
|
||||
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 removeMensual = (index) => {
|
||||
|
||||
if (!isRowEditable(mensualizados[index], canEditSheet, isAdmin)) return;
|
||||
|
||||
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) => {
|
||||
|
||||
if (!isRowEditable(eventuales[index], canEditSheet, isAdmin)) return;
|
||||
|
||||
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()];
|
||||
|
||||
const msg = gestion?.fechaCierreGestion && !isAdmin
|
||||
|
||||
? '¿Eliminar solo filas editables? Las del período cerrado se conservarán.'
|
||||
|
||||
: '¿Vaciar toda la planilla de personal de la gestión actual?';
|
||||
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
skipNotifyRef.current = true;
|
||||
setMensualizados(m);
|
||||
setEventuales(e);
|
||||
saveNow({ mensualizados: m, eventuales: e });
|
||||
|
||||
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||||
|
||||
const m = [emptyMensualizado()];
|
||||
|
||||
const e = [emptyEventual()];
|
||||
|
||||
setMensualizados(m);
|
||||
|
||||
setEventuales(e);
|
||||
|
||||
saveNow({ mensualizados: m, eventuales: e });
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const m = mensualizados.filter((r) => r.bloqueada);
|
||||
|
||||
const e = eventuales.filter((r) => r.bloqueada);
|
||||
|
||||
setMensualizados(m.length ? m : [emptyMensualizado()]);
|
||||
|
||||
setEventuales(e.length ? e : [emptyEventual()]);
|
||||
|
||||
saveNow({
|
||||
|
||||
mensualizados: m.length ? m : [emptyMensualizado()],
|
||||
|
||||
eventuales: e.length ? e : [emptyEventual()],
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const sinDatos = mensualizados.length === 0 && eventuales.length === 0;
|
||||
|
||||
|
||||
|
||||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Planilla de Personal</h1>
|
||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||
{editable && <SaveStatus status={status} error={saveError} />}
|
||||
|
||||
<div className="toolbar">
|
||||
{editable && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-icon"
|
||||
disabled={mensualizados.length >= MAX_MENSUAL}
|
||||
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
||||
title="Agregar mensualizado"
|
||||
>
|
||||
+ Mensual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon"
|
||||
disabled={eventuales.length >= MAX_EVENTUAL}
|
||||
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
||||
title="Agregar eventual"
|
||||
>
|
||||
+ Eventual
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
<div className="sheet-layout">
|
||||
|
||||
<GestionesSidebar
|
||||
|
||||
gestiones={sidebarGestiones}
|
||||
|
||||
selectedId={selectedGestionId}
|
||||
|
||||
onSelect={selectGestion}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="sheet-layout__main">
|
||||
|
||||
<h1 className="page-title">Planilla de Personal</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>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
|
||||
<div className="sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Sueldo base</th>
|
||||
<th>Cargas sociales</th>
|
||||
<th>Bonos</th>
|
||||
<th>Subtotal</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mensualizados.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.sueldoBase ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.cargasSociales ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</table>
|
||||
|
||||
|
||||
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
|
||||
|
||||
{canEditSheet && <SaveStatus status={status} error={saveError} />}
|
||||
|
||||
{esHistorial && <div className="alert alert-info">Gestión cerrada — solo consulta</div>}
|
||||
|
||||
|
||||
|
||||
<div className="toolbar">
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-primary btn-icon"
|
||||
|
||||
disabled={mensualizados.length >= MAX_MENSUAL}
|
||||
|
||||
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
||||
|
||||
title="Agregar mensualizado"
|
||||
|
||||
>
|
||||
|
||||
+ Mensual
|
||||
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-secondary btn-icon"
|
||||
|
||||
disabled={eventuales.length >= MAX_EVENTUAL}
|
||||
|
||||
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
||||
|
||||
title="Agregar eventual"
|
||||
|
||||
>
|
||||
|
||||
+ Eventual
|
||||
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
|
||||
<div className="sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Nombre</th>
|
||||
<th>Pago diario</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eventuales.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
||||
</td>
|
||||
<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>
|
||||
</table>
|
||||
|
||||
|
||||
{sinDatos ? (
|
||||
|
||||
<p className="loading">Sin registros en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
|
||||
|
||||
{mensualizados.length === 0 ? (
|
||||
|
||||
<p style={{ color: 'var(--muted)' }}>Sin mensualizados en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<div className="sheet-table-wrap">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Nombre</th>
|
||||
|
||||
<th>Sueldo base</th>
|
||||
|
||||
<th>Cargas sociales</th>
|
||||
|
||||
<th>Bonos</th>
|
||||
|
||||
<th>Subtotal</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{mensualizados.map((row, i) => {
|
||||
|
||||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||
|
||||
return (
|
||||
|
||||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.sueldoBase ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.cargasSociales ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.bonos ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={() => removeMensual(i)}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="card">
|
||||
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
|
||||
|
||||
{eventuales.length === 0 ? (
|
||||
|
||||
<p style={{ color: 'var(--muted)' }}>Sin eventuales en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<div className="sheet-table-wrap">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Fecha</th>
|
||||
|
||||
<th>Nombre</th>
|
||||
|
||||
<th>Pago diario</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{eventuales.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) => updateEventual(i, 'fecha', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.pagoDiario ?? ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={() => removeEventual(i)}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
||||
<span>
|
||||
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
29
frontend/src/hooks/useGestionView.js
Normal file
29
frontend/src/hooks/useGestionView.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { gestionesApi } from '../api/client';
|
||||
|
||||
export function useGestionView() {
|
||||
const [selectedGestionId, setSelectedGestionId] = useState('actual');
|
||||
const [gestionesList, setGestionesList] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
gestionesApi
|
||||
.list()
|
||||
.then((data) => {
|
||||
if (active) setGestionesList(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedGestionId,
|
||||
selectGestion: setSelectedGestionId,
|
||||
gestionesList,
|
||||
sidebarGestiones: gestionesList
|
||||
? { gestionActual: gestionesList.actual, historial: gestionesList.historial }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usersApi } from '../api/client';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { usersApi, settingsApi } from '../api/client';
|
||||
|
||||
const SHEET_LABELS = {
|
||||
ventas: 'Ventas',
|
||||
@@ -22,12 +23,30 @@ export default function AdminPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', permissions: emptyPerms() });
|
||||
const [gestion, setGestion] = useState(null);
|
||||
const [fechaCierre, setFechaCierre] = useState('');
|
||||
const [gestionMsg, setGestionMsg] = useState('');
|
||||
const [gestionError, setGestionError] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
const load = async () => {
|
||||
setError('');
|
||||
setGestionError('');
|
||||
try {
|
||||
setUsers(await usersApi.list());
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
try {
|
||||
const gestionData = await settingsApi.getGestion();
|
||||
setGestion(gestionData);
|
||||
setFechaCierre(gestionData.sugerirCierre || gestionData.fechaCierreGestion || '');
|
||||
} catch (e) {
|
||||
setGestionError(
|
||||
e.message.includes('404') || e.message.includes('Error en la solicitud')
|
||||
? 'No se pudo cargar el cierre de gestión. Reconstruya Docker: docker compose build --no-cache && docker compose up -d'
|
||||
: e.message
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -37,6 +56,12 @@ export default function AdminPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash === '#cierre-gestion') {
|
||||
document.getElementById('cierre-gestion')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [location.hash, loading]);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@@ -69,18 +94,112 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="loading">Cargando usuarios…</p>;
|
||||
const cerrarGestion = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!fechaCierre) {
|
||||
setError('Indique el último día de la gestión a cerrar.');
|
||||
return;
|
||||
}
|
||||
const etiqueta = gestion?.gestionActual?.fechaInicio
|
||||
? `del ${gestion.gestionActual.fechaInicio} al ${fechaCierre}`
|
||||
: `hasta el ${fechaCierre}`;
|
||||
if (
|
||||
!window.confirm(
|
||||
`¿Cerrar la gestión ${etiqueta}?\n\nSe bloqueará el período para todos (excepto JEFE) y comenzará una gestión nueva. El historial quedará en la barra lateral de cada planilla.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setGestionMsg('');
|
||||
setError('');
|
||||
try {
|
||||
const data = await settingsApi.cerrarGestion(fechaCierre);
|
||||
setGestion(data);
|
||||
setFechaCierre(data.sugerirCierre || '');
|
||||
const nombre = data.cierre?.cerrada?.etiqueta || 'gestión cerrada';
|
||||
setGestionMsg(`"${nombre}" cerrada. Nueva gestión desde ${data.cierre?.nueva?.fechaInicio || 'mañana'}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="loading">Cargando administración…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Administración de usuarios</h1>
|
||||
<h1 className="page-title">Administración</h1>
|
||||
<p style={{ color: 'var(--muted)' }}>
|
||||
El superusuario <strong>JEFE</strong> puede crear usuarios, asignar contraseñas y habilitar acceso por planilla.
|
||||
Solo visible para el administrador <strong>JEFE</strong>. Gestione el cierre del período y los usuarios.
|
||||
</p>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Nuevo usuario</h2>
|
||||
<nav className="admin-subnav">
|
||||
<a href="#cierre-gestion" className="admin-subnav__link admin-subnav__link--active">
|
||||
Cierre de gestión
|
||||
</a>
|
||||
<a href="#usuarios" className="admin-subnav__link">
|
||||
Usuarios
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{gestionMsg && <div className="alert alert-success">{gestionMsg}</div>}
|
||||
|
||||
<div id="cierre-gestion" className="card card-highlight admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Cierre de gestión</h2>
|
||||
{gestionError && <div className="alert alert-error">{gestionError}</div>}
|
||||
|
||||
<div className="alert alert-info" style={{ fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
<strong>¿Cómo funciona?</strong>
|
||||
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
|
||||
<li>Al <strong>cerrar la gestión</strong> (típicamente fin de mes), el período queda bloqueado de inmediato.</li>
|
||||
<li>Cada planilla muestra solo la <strong>gestión actual</strong>; el historial está en la <strong>barra lateral</strong> (solo lectura).</li>
|
||||
<li>La nueva gestión comienza el día siguiente al cierre. Use el botón <strong>+</strong> para cargar datos nuevos.</li>
|
||||
<li>Filas con fecha del período cerrado no se pueden editar (excepto JEFE).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{gestion?.gestionActual && (
|
||||
<p style={{ fontSize: '0.85rem', marginBottom: '1rem' }}>
|
||||
Gestión actual: desde <strong>{gestion.gestionActual.fechaInicio}</strong> — Hoy:{' '}
|
||||
<strong>{gestion.hoy}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{gestion?.historial?.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<p style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}>
|
||||
<strong>Gestiones cerradas:</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', fontSize: '0.85rem' }}>
|
||||
{gestion.historial.map((g) => (
|
||||
<li key={g.id}>
|
||||
{g.etiqueta} ({g.fechaInicio} → {g.fechaFin})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={cerrarGestion}>
|
||||
<div className="form-group">
|
||||
<label>Último día de la gestión a cerrar</label>
|
||||
<input type="date" value={fechaCierre} onChange={(e) => setFechaCierre(e.target.value)} required />
|
||||
{gestion?.sugerirCierre && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', marginTop: '0.35rem' }}>
|
||||
Sugerido (fin de mes): {gestion.sugerirCierre}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Cerrar gestión
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="usuarios" className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Usuarios — Nuevo usuario</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Usuario</label>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h1 className="page-title">Menú principal</h1>
|
||||
<p>
|
||||
Bienvenido, <strong>{user?.username}</strong>. Utilice la barra superior para acceder a cada planilla.
|
||||
</p>
|
||||
<ul style={{ color: 'var(--muted)', lineHeight: 1.8 }}>
|
||||
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
||||
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
||||
<li>Planilla de Personal y Reportes consolidados</li>
|
||||
{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 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>
|
||||
<div className="card">
|
||||
<h1 className="page-title">Menú principal</h1>
|
||||
<p>
|
||||
Bienvenido, <strong>{user?.username}</strong>. Utilice la barra superior para acceder a cada planilla.
|
||||
</p>
|
||||
<ul style={{ color: 'var(--muted)', lineHeight: 1.8 }}>
|
||||
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
||||
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
||||
<li>Planilla de Personal y Reportes consolidados</li>
|
||||
</ul>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
|
||||
Los datos se guardan automáticamente en el servidor. Use el botón <strong>+</strong> para agregar filas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<div className="card card-highlight" style={{ marginTop: '1rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Administración (JEFE)</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '1rem' }}>
|
||||
Cierre de gestión, usuarios y permisos en un solo lugar.
|
||||
</p>
|
||||
<Link to="/admin" className="btn btn-primary">
|
||||
Ir a Administración
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,6 +382,211 @@ textarea {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sheet-table tr.row-locked {
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
}
|
||||
|
||||
.sheet-table tr.row-locked input,
|
||||
.sheet-table tr.row-locked select {
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-tab--cierre {
|
||||
border-color: #ff6b6b;
|
||||
color: #ffb0b0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-tab--cierre.active {
|
||||
background: #ff6b6b;
|
||||
color: #fff;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.nav-tab--admin {
|
||||
border-color: var(--warning);
|
||||
color: #ffd080;
|
||||
}
|
||||
|
||||
.nav-tab--admin.active {
|
||||
background: var(--warning);
|
||||
color: #1a1200;
|
||||
border-color: var(--warning);
|
||||
}
|
||||
|
||||
.card-highlight {
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(61, 156, 245, 0.25), var(--shadow);
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(61, 156, 245, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-subnav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-subnav__link {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-subnav__link--active,
|
||||
.admin-subnav__link:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sheet-layout {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sheet-layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gestiones-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gestiones-sidebar__title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gestiones-sidebar__subtitle {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.gestiones-sidebar__empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.gestion-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gestion-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.gestion-item--actual {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gestion-item--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gestion-item__label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gestion-item__range {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.historial-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 176, 32, 0.15);
|
||||
border: 1px solid var(--warning);
|
||||
color: #ffd080;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sheet-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.gestiones-sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.gestion-list {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gestion-item {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.5rem;
|
||||
|
||||
17
frontend/src/utils/gestion.js
Normal file
17
frontend/src/utils/gestion.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isRowEditable(row, editable, isAdmin) {
|
||||
if (!editable) return false;
|
||||
if (isAdmin) return true;
|
||||
return !row?.bloqueada;
|
||||
}
|
||||
|
||||
export function gestionMensaje(gestion, isAdmin) {
|
||||
if (!gestion?.fechaCierreGestion) return null;
|
||||
|
||||
const cierre = gestion.fechaCierreGestion;
|
||||
|
||||
if (isAdmin) {
|
||||
return `Cierre de gestión activo (${cierre}). Las filas con fecha ≤ ${cierre} están bloqueadas para otros usuarios. Como administrador usted puede editarlas.`;
|
||||
}
|
||||
|
||||
return `Cierre de gestión activo (${cierre}). No puede editar filas con fecha ≤ ${cierre}. Solo puede cargar filas nuevas con fecha posterior al cierre (botón +).`;
|
||||
}
|
||||
Reference in New Issue
Block a user