no se que modificaciones se hizo posible error

This commit is contained in:
unknown
2026-06-13 12:04:04 -04:00
parent ff90340fdb
commit fa05ae3754
24 changed files with 2466 additions and 402 deletions

View File

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

View File

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

View File

@@ -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'),
};

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View 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,
};
}

View File

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

View File

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

View File

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

View 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 +).`;
}