Add complete admin forms for Usuarios, Umbrales, and ReglasMapeeo
- Usuarios: Full CRUD with role selection and client assignment - Umbrales: Form with metric selection and threshold values - ReglasMapeeo: Form with regex patterns and category mapping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,176 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { adminApi } from '../../services/api';
|
import { adminApi } from '../../services/api';
|
||||||
import { ReglaMapeo } from '../../types';
|
import { ReglaMapeo, ReporteContable, CategoriaContable } from '../../types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const SISTEMAS = ['contpaqi', 'aspel', 'sap', 'odoo', 'alegra', 'generico'];
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
sistema_origen: string;
|
||||||
|
cuenta_padre_codigo: string;
|
||||||
|
rango_inicio: string;
|
||||||
|
rango_fin: string;
|
||||||
|
patron_regex: string;
|
||||||
|
reporte_contable_id: number | null;
|
||||||
|
categoria_contable_id: number | null;
|
||||||
|
prioridad: number;
|
||||||
|
activo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: FormData = {
|
||||||
|
sistema_origen: 'contpaqi',
|
||||||
|
cuenta_padre_codigo: '',
|
||||||
|
rango_inicio: '',
|
||||||
|
rango_fin: '',
|
||||||
|
patron_regex: '',
|
||||||
|
reporte_contable_id: null,
|
||||||
|
categoria_contable_id: null,
|
||||||
|
prioridad: 10,
|
||||||
|
activo: true,
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminReglasMapeeo() {
|
export default function AdminReglasMapeeo() {
|
||||||
const [reglas, setReglas] = useState<ReglaMapeo[]>([]);
|
const [reglas, setReglas] = useState<ReglaMapeo[]>([]);
|
||||||
|
const [reportesContables, setReportesContables] = useState<ReporteContable[]>([]);
|
||||||
|
const [categoriasContables, setCategoriasContables] = useState<CategoriaContable[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterSistema, setFilterSistema] = useState<string>('');
|
const [filterSistema, setFilterSistema] = useState<string>('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
const sistemas = ['contpaqi', 'aspel', 'sap', 'odoo', 'alegra', 'generico'];
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadReglas();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadReglas = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.reglasMapeeo.list();
|
const [reglasData, reportesData, categoriasData] = await Promise.all([
|
||||||
setReglas(data);
|
adminApi.reglasMapeeo.list(),
|
||||||
|
adminApi.catalogos.reportesContables(),
|
||||||
|
adminApi.catalogos.categoriasContables(),
|
||||||
|
]);
|
||||||
|
setReglas(reglasData);
|
||||||
|
setReportesContables(reportesData);
|
||||||
|
setCategoriasContables(categoriasData);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al cargar reglas');
|
toast.error('Error al cargar datos');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setEditingId(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (regla: ReglaMapeo) => {
|
||||||
|
setFormData({
|
||||||
|
sistema_origen: regla.sistema_origen,
|
||||||
|
cuenta_padre_codigo: regla.cuenta_padre_codigo || '',
|
||||||
|
rango_inicio: regla.rango_inicio || '',
|
||||||
|
rango_fin: regla.rango_fin || '',
|
||||||
|
patron_regex: regla.patron_regex || '',
|
||||||
|
reporte_contable_id: regla.reporte_contable_id,
|
||||||
|
categoria_contable_id: regla.categoria_contable_id,
|
||||||
|
prioridad: regla.prioridad,
|
||||||
|
activo: regla.activo,
|
||||||
|
});
|
||||||
|
setEditingId(regla.id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||||
|
} else if (name === 'reporte_contable_id' || name === 'categoria_contable_id') {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value ? Number(value) : null }));
|
||||||
|
} else if (name === 'prioridad') {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: Number(value) }));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.sistema_origen) {
|
||||||
|
toast.error('Seleccione un sistema');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.reporte_contable_id) {
|
||||||
|
toast.error('Seleccione un reporte contable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.categoria_contable_id) {
|
||||||
|
toast.error('Seleccione una categoría');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const dataToSend = {
|
||||||
|
sistema_origen: formData.sistema_origen,
|
||||||
|
cuenta_padre_codigo: formData.cuenta_padre_codigo || null,
|
||||||
|
rango_inicio: formData.rango_inicio || null,
|
||||||
|
rango_fin: formData.rango_fin || null,
|
||||||
|
patron_regex: formData.patron_regex || null,
|
||||||
|
reporte_contable_id: formData.reporte_contable_id,
|
||||||
|
categoria_contable_id: formData.categoria_contable_id,
|
||||||
|
prioridad: formData.prioridad,
|
||||||
|
activo: formData.activo,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
await adminApi.reglasMapeeo.update(editingId, dataToSend);
|
||||||
|
toast.success('Regla actualizada');
|
||||||
|
} else {
|
||||||
|
await adminApi.reglasMapeeo.create(dataToSend);
|
||||||
|
toast.success('Regla creada');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose();
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Error al guardar regla');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('¿Estás seguro de eliminar esta regla?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.reglasMapeeo.delete(id);
|
||||||
|
toast.success('Regla eliminada');
|
||||||
|
loadData();
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al eliminar regla');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredReglas = filterSistema
|
const filteredReglas = filterSistema
|
||||||
? reglas.filter((r) => r.sistema_origen === filterSistema)
|
? reglas.filter((r) => r.sistema_origen === filterSistema)
|
||||||
: reglas;
|
: reglas;
|
||||||
|
|
||||||
|
const categoriasFiltradas = formData.reporte_contable_id
|
||||||
|
? categoriasContables.filter((c) => c.reporte_contable_id === formData.reporte_contable_id)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -41,7 +183,9 @@ export default function AdminReglasMapeeo() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Reglas de Mapeo Contable</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Reglas de Mapeo Contable</h1>
|
||||||
<button className="btn btn-primary">+ Nueva Regla</button>
|
<button onClick={handleOpenCreate} className="btn btn-primary">
|
||||||
|
+ Nueva Regla
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtro */}
|
{/* Filtro */}
|
||||||
@@ -49,10 +193,10 @@ export default function AdminReglasMapeeo() {
|
|||||||
<select
|
<select
|
||||||
value={filterSistema}
|
value={filterSistema}
|
||||||
onChange={(e) => setFilterSistema(e.target.value)}
|
onChange={(e) => setFilterSistema(e.target.value)}
|
||||||
className="input max-w-xs"
|
className="px-3 py-2 border border-gray-300 rounded-lg max-w-xs"
|
||||||
>
|
>
|
||||||
<option value="">Todos los sistemas</option>
|
<option value="">Todos los sistemas</option>
|
||||||
{sistemas.map((sistema) => (
|
{SISTEMAS.map((sistema) => (
|
||||||
<option key={sistema} value={sistema}>
|
<option key={sistema} value={sistema}>
|
||||||
{sistema.charAt(0).toUpperCase() + sistema.slice(1)}
|
{sistema.charAt(0).toUpperCase() + sistema.slice(1)}
|
||||||
</option>
|
</option>
|
||||||
@@ -76,7 +220,7 @@ export default function AdminReglasMapeeo() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{filteredReglas.map((regla) => (
|
{filteredReglas.map((regla) => (
|
||||||
<tr key={regla.id}>
|
<tr key={regla.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 capitalize">{regla.sistema_origen}</td>
|
<td className="px-4 py-3 capitalize">{regla.sistema_origen}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs">
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
{regla.cuenta_padre_codigo || '-'}
|
{regla.cuenta_padre_codigo || '-'}
|
||||||
@@ -102,13 +246,28 @@ export default function AdminReglasMapeeo() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button className="text-primary-600 hover:text-primary-700 mr-3">
|
<button
|
||||||
|
onClick={() => handleOpenEdit(regla)}
|
||||||
|
className="text-primary-600 hover:text-primary-700 mr-3"
|
||||||
|
>
|
||||||
Editar
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button className="text-red-600 hover:text-red-700">Eliminar</button>
|
<button
|
||||||
|
onClick={() => handleDelete(regla.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{filteredReglas.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No hay reglas configuradas
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +276,196 @@ export default function AdminReglasMapeeo() {
|
|||||||
<p>Las reglas determinan cómo se clasifican las cuentas de cada sistema contable.</p>
|
<p>Las reglas determinan cómo se clasifican las cuentas de cada sistema contable.</p>
|
||||||
<p>Las reglas con mayor prioridad se evalúan primero.</p>
|
<p>Las reglas con mayor prioridad se evalúan primero.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de formulario */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
{editingId ? 'Editar Regla' : 'Nueva Regla'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Sistema */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Sistema origen *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="sistema_origen"
|
||||||
|
value={formData.sistema_origen}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
{SISTEMAS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prioridad */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Prioridad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="prioridad"
|
||||||
|
value={formData.prioridad}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Mayor número = mayor prioridad</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cuenta padre */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Código cuenta padre (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="cuenta_padre_codigo"
|
||||||
|
value={formData.cuenta_padre_codigo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 font-mono"
|
||||||
|
placeholder="Ej: 001-100-000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rango */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rango inicio
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="rango_inicio"
|
||||||
|
value={formData.rango_inicio}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 font-mono"
|
||||||
|
placeholder="Ej: 101-000-000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rango fin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="rango_fin"
|
||||||
|
value={formData.rango_fin}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 font-mono"
|
||||||
|
placeholder="Ej: 154-999-999"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patrón Regex */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Patrón Regex (alternativo al rango)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="patron_regex"
|
||||||
|
value={formData.patron_regex}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 font-mono"
|
||||||
|
placeholder="Ej: ^30\d-\d{3}-\d{3}$"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Use expresiones regulares para patrones complejos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reporte Contable */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Reporte contable *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="reporte_contable_id"
|
||||||
|
value={formData.reporte_contable_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
{reportesContables.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categoría Contable */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Categoría contable *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="categoria_contable_id"
|
||||||
|
value={formData.categoria_contable_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={!formData.reporte_contable_id}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{formData.reporte_contable_id ? 'Seleccionar...' : 'Primero seleccione reporte'}
|
||||||
|
</option>
|
||||||
|
{categoriasFiltradas.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activo */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="activo"
|
||||||
|
checked={formData.activo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Regla activa</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botones */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="btn btn-secondary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,158 @@ import { adminApi } from '../../services/api';
|
|||||||
import { Umbral, Giro } from '../../types';
|
import { Umbral, Giro } from '../../types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const METRICAS = [
|
||||||
|
{ value: 'margen_bruto', label: 'Margen Bruto' },
|
||||||
|
{ value: 'margen_ebitda', label: 'Margen EBITDA' },
|
||||||
|
{ value: 'margen_operativo', label: 'Margen Operativo' },
|
||||||
|
{ value: 'margen_neto', label: 'Margen Neto' },
|
||||||
|
{ value: 'margen_nopat', label: 'Margen NOPAT' },
|
||||||
|
{ value: 'margen_ocf', label: 'Margen OCF' },
|
||||||
|
{ value: 'margen_fcf', label: 'Margen FCF' },
|
||||||
|
{ value: 'roic', label: 'ROIC' },
|
||||||
|
{ value: 'roe', label: 'ROE' },
|
||||||
|
{ value: 'roa', label: 'ROA' },
|
||||||
|
{ value: 'roce', label: 'ROCE' },
|
||||||
|
{ value: 'current_ratio', label: 'Current Ratio' },
|
||||||
|
{ value: 'quick_ratio', label: 'Quick Ratio' },
|
||||||
|
{ value: 'cash_ratio', label: 'Cash Ratio' },
|
||||||
|
{ value: 'net_debt_ebitda', label: 'Net Debt/EBITDA' },
|
||||||
|
{ value: 'interest_coverage', label: 'Interest Coverage' },
|
||||||
|
{ value: 'debt_ratio', label: 'Debt Ratio' },
|
||||||
|
{ value: 'asset_turnover', label: 'Asset Turnover' },
|
||||||
|
{ value: 'inventory_turnover', label: 'Inventory Turnover' },
|
||||||
|
{ value: 'revenue_growth', label: 'Revenue Growth' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
metrica: string;
|
||||||
|
giro_id: number | null;
|
||||||
|
muy_positivo: string;
|
||||||
|
positivo: string;
|
||||||
|
neutral: string;
|
||||||
|
negativo: string;
|
||||||
|
muy_negativo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: FormData = {
|
||||||
|
metrica: '',
|
||||||
|
giro_id: null,
|
||||||
|
muy_positivo: '',
|
||||||
|
positivo: '',
|
||||||
|
neutral: '',
|
||||||
|
negativo: '',
|
||||||
|
muy_negativo: '',
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminUmbrales() {
|
export default function AdminUmbrales() {
|
||||||
const [umbrales, setUmbrales] = useState<Umbral[]>([]);
|
const [umbrales, setUmbrales] = useState<Umbral[]>([]);
|
||||||
const [giros, setGiros] = useState<Giro[]>([]);
|
const [giros, setGiros] = useState<Giro[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filterGiro, setFilterGiro] = useState<string>('');
|
const [filterGiro, setFilterGiro] = useState<string>('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadUmbrales(), loadGiros()]);
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUmbrales = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.umbrales.list();
|
const [umbralesData, girosData] = await Promise.all([
|
||||||
setUmbrales(data);
|
adminApi.umbrales.list(),
|
||||||
|
adminApi.giros.list(),
|
||||||
|
]);
|
||||||
|
setUmbrales(umbralesData);
|
||||||
|
setGiros(girosData);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al cargar umbrales');
|
toast.error('Error al cargar datos');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadGiros = async () => {
|
const handleOpenCreate = () => {
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setEditingId(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (umbral: Umbral) => {
|
||||||
|
setFormData({
|
||||||
|
metrica: umbral.metrica,
|
||||||
|
giro_id: umbral.giro_id || null,
|
||||||
|
muy_positivo: umbral.muy_positivo?.toString() || '',
|
||||||
|
positivo: umbral.positivo?.toString() || '',
|
||||||
|
neutral: umbral.neutral?.toString() || '',
|
||||||
|
negativo: umbral.negativo?.toString() || '',
|
||||||
|
muy_negativo: umbral.muy_negativo?.toString() || '',
|
||||||
|
});
|
||||||
|
setEditingId(umbral.id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'giro_id' ? (value ? Number(value) : null) : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.metrica) {
|
||||||
|
toast.error('Seleccione una métrica');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.giros.list();
|
const dataToSend = {
|
||||||
setGiros(data);
|
metrica: formData.metrica,
|
||||||
|
giro_id: formData.giro_id,
|
||||||
|
muy_positivo: formData.muy_positivo ? parseFloat(formData.muy_positivo) : null,
|
||||||
|
positivo: formData.positivo ? parseFloat(formData.positivo) : null,
|
||||||
|
neutral: formData.neutral ? parseFloat(formData.neutral) : null,
|
||||||
|
negativo: formData.negativo ? parseFloat(formData.negativo) : null,
|
||||||
|
muy_negativo: formData.muy_negativo ? parseFloat(formData.muy_negativo) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
await adminApi.umbrales.update(editingId, dataToSend);
|
||||||
|
toast.success('Umbral actualizado');
|
||||||
|
} else {
|
||||||
|
await adminApi.umbrales.create(dataToSend);
|
||||||
|
toast.success('Umbral creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose();
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Error al guardar umbral');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('¿Estás seguro de eliminar este umbral?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.umbrales.delete(id);
|
||||||
|
toast.success('Umbral eliminado');
|
||||||
|
loadData();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al cargar giros');
|
toast.error('Error al eliminar umbral');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +162,11 @@ export default function AdminUmbrales() {
|
|||||||
? umbrales.filter((u) => u.giro_id?.toString() === filterGiro || (!u.giro_id && filterGiro === 'general'))
|
? umbrales.filter((u) => u.giro_id?.toString() === filterGiro || (!u.giro_id && filterGiro === 'general'))
|
||||||
: umbrales;
|
: umbrales;
|
||||||
|
|
||||||
|
const formatValue = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
return value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -49,7 +179,9 @@ export default function AdminUmbrales() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Umbrales de Métricas</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Umbrales de Métricas</h1>
|
||||||
<button className="btn btn-primary">+ Nuevo Umbral</button>
|
<button onClick={handleOpenCreate} className="btn btn-primary">
|
||||||
|
+ Nuevo Umbral
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtro */}
|
{/* Filtro */}
|
||||||
@@ -57,7 +189,7 @@ export default function AdminUmbrales() {
|
|||||||
<select
|
<select
|
||||||
value={filterGiro}
|
value={filterGiro}
|
||||||
onChange={(e) => setFilterGiro(e.target.value)}
|
onChange={(e) => setFilterGiro(e.target.value)}
|
||||||
className="input max-w-xs"
|
className="px-3 py-2 border border-gray-300 rounded-lg max-w-xs"
|
||||||
>
|
>
|
||||||
<option value="">Todos los umbrales</option>
|
<option value="">Todos los umbrales</option>
|
||||||
<option value="general">Umbrales generales</option>
|
<option value="general">Umbrales generales</option>
|
||||||
@@ -85,21 +217,39 @@ export default function AdminUmbrales() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{filteredUmbrales.map((umbral) => (
|
{filteredUmbrales.map((umbral) => (
|
||||||
<tr key={umbral.id}>
|
<tr key={umbral.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 font-medium">{umbral.metrica}</td>
|
<td className="px-4 py-3 font-medium">{umbral.metrica}</td>
|
||||||
<td className="px-4 py-3 text-gray-500">
|
<td className="px-4 py-3 text-gray-500">
|
||||||
{umbral.giro?.nombre || 'General'}
|
{umbral.giro?.nombre || 'General'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">{umbral.muy_positivo ?? '-'}</td>
|
<td className="px-4 py-3 text-center">{formatValue(umbral.muy_positivo)}</td>
|
||||||
<td className="px-4 py-3 text-center">{umbral.positivo ?? '-'}</td>
|
<td className="px-4 py-3 text-center">{formatValue(umbral.positivo)}</td>
|
||||||
<td className="px-4 py-3 text-center">{umbral.neutral ?? '-'}</td>
|
<td className="px-4 py-3 text-center">{formatValue(umbral.neutral)}</td>
|
||||||
<td className="px-4 py-3 text-center">{umbral.negativo ?? '-'}</td>
|
<td className="px-4 py-3 text-center">{formatValue(umbral.negativo)}</td>
|
||||||
<td className="px-4 py-3 text-center">{umbral.muy_negativo ?? '-'}</td>
|
<td className="px-4 py-3 text-center">{formatValue(umbral.muy_negativo)}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button className="text-primary-600 hover:text-primary-700">Editar</button>
|
<button
|
||||||
|
onClick={() => handleOpenEdit(umbral)}
|
||||||
|
className="text-primary-600 hover:text-primary-700 mr-3"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(umbral.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{filteredUmbrales.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No hay umbrales configurados
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +258,160 @@ export default function AdminUmbrales() {
|
|||||||
<p>Los umbrales determinan el color del semáforo para cada métrica.</p>
|
<p>Los umbrales determinan el color del semáforo para cada métrica.</p>
|
||||||
<p>Los umbrales por giro tienen prioridad sobre los generales.</p>
|
<p>Los umbrales por giro tienen prioridad sobre los generales.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de formulario */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl max-w-lg w-full p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
{editingId ? 'Editar Umbral' : 'Nuevo Umbral'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Métrica */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Métrica *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="metrica"
|
||||||
|
value={formData.metrica}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
disabled={!!editingId}
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar métrica...</option>
|
||||||
|
{METRICAS.map((m) => (
|
||||||
|
<option key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Giro */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Giro (opcional)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="giro_id"
|
||||||
|
value={formData.giro_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">General (aplica a todos)</option>
|
||||||
|
{giros.map((giro) => (
|
||||||
|
<option key={giro.id} value={giro.id}>
|
||||||
|
{giro.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Si selecciona un giro, este umbral solo aplicará a clientes de ese giro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Umbrales */}
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-emerald-600 mb-1 text-center">
|
||||||
|
Muy +
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="muy_positivo"
|
||||||
|
value={formData.muy_positivo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-2 py-2 border border-gray-300 rounded-lg text-center text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="≥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-emerald-400 mb-1 text-center">
|
||||||
|
Positivo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="positivo"
|
||||||
|
value={formData.positivo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-2 py-2 border border-gray-300 rounded-lg text-center text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="≥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-amber-500 mb-1 text-center">
|
||||||
|
Neutral
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="neutral"
|
||||||
|
value={formData.neutral}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-2 py-2 border border-gray-300 rounded-lg text-center text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="≥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-orange-500 mb-1 text-center">
|
||||||
|
Negativo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="negativo"
|
||||||
|
value={formData.negativo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-2 py-2 border border-gray-300 rounded-lg text-center text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="≥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-red-500 mb-1 text-center">
|
||||||
|
Muy -
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
name="muy_negativo"
|
||||||
|
value={formData.muy_negativo}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-2 py-2 border border-gray-300 rounded-lg text-center text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="<"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Para métricas de porcentaje, use decimales (ej: 0.20 = 20%).
|
||||||
|
Para ratios, use el valor directo (ej: 1.5).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Botones */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="btn btn-secondary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,163 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { adminApi } from '../../services/api';
|
import { adminApi, clientesApi } from '../../services/api';
|
||||||
import { User } from '../../types';
|
import { User, Cliente } from '../../types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
nombre: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: 'admin' | 'analista' | 'cliente' | 'empleado';
|
||||||
|
cliente_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: FormData = {
|
||||||
|
nombre: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'analista',
|
||||||
|
cliente_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminUsuarios() {
|
export default function AdminUsuarios() {
|
||||||
const [usuarios, setUsuarios] = useState<User[]>([]);
|
const [usuarios, setUsuarios] = useState<User[]>([]);
|
||||||
|
const [clientes, setClientes] = useState<Cliente[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsuarios();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUsuarios = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.usuarios.list();
|
const [usuariosData, clientesData] = await Promise.all([
|
||||||
setUsuarios(data);
|
adminApi.usuarios.list(),
|
||||||
|
clientesApi.list(),
|
||||||
|
]);
|
||||||
|
setUsuarios(usuariosData);
|
||||||
|
setClientes(clientesData);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al cargar usuarios');
|
toast.error('Error al cargar datos');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setEditingUser(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (usuario: User) => {
|
||||||
|
setFormData({
|
||||||
|
nombre: usuario.nombre,
|
||||||
|
email: usuario.email,
|
||||||
|
password: '',
|
||||||
|
role: usuario.role,
|
||||||
|
cliente_id: usuario.cliente_id || null,
|
||||||
|
});
|
||||||
|
setEditingUser(usuario);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'cliente_id' ? (value ? Number(value) : null) : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validaciones
|
||||||
|
if (!formData.nombre.trim()) {
|
||||||
|
toast.error('El nombre es requerido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
toast.error('El email es requerido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingUser && !formData.password) {
|
||||||
|
toast.error('La contraseña es requerida');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((formData.role === 'cliente' || formData.role === 'empleado') && !formData.cliente_id) {
|
||||||
|
toast.error('Debe seleccionar un cliente para este rol');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const dataToSend: any = {
|
||||||
|
nombre: formData.nombre,
|
||||||
|
email: formData.email,
|
||||||
|
role: formData.role,
|
||||||
|
cliente_id: formData.cliente_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.password) {
|
||||||
|
dataToSend.password = formData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingUser) {
|
||||||
|
await adminApi.usuarios.update(editingUser.id, dataToSend);
|
||||||
|
toast.success('Usuario actualizado');
|
||||||
|
} else {
|
||||||
|
await adminApi.usuarios.create(dataToSend);
|
||||||
|
toast.success('Usuario creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose();
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message || 'Error al guardar usuario';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('¿Estás seguro de eliminar este usuario?')) return;
|
if (!confirm('¿Estás seguro de eliminar este usuario?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await adminApi.usuarios.delete(id);
|
await adminApi.usuarios.delete(id);
|
||||||
toast.success('Usuario eliminado');
|
toast.success('Usuario eliminado');
|
||||||
loadUsuarios();
|
loadData();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al eliminar usuario');
|
toast.error('Error al eliminar usuario');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
case 'analista':
|
||||||
|
return 'bg-blue-100 text-blue-700';
|
||||||
|
case 'cliente':
|
||||||
|
return 'bg-green-100 text-green-700';
|
||||||
|
case 'empleado':
|
||||||
|
return 'bg-yellow-100 text-yellow-700';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -48,7 +170,7 @@ export default function AdminUsuarios() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||||
<button onClick={() => setShowForm(true)} className="btn btn-primary">
|
<button onClick={handleOpenCreate} className="btn btn-primary">
|
||||||
+ Nuevo Usuario
|
+ Nuevo Usuario
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +188,11 @@ export default function AdminUsuarios() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{usuarios.map((usuario) => (
|
{usuarios.map((usuario) => (
|
||||||
<tr key={usuario.id}>
|
<tr key={usuario.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3">{usuario.nombre}</td>
|
<td className="px-4 py-3 font-medium">{usuario.nombre}</td>
|
||||||
<td className="px-4 py-3 text-gray-500">{usuario.email}</td>
|
<td className="px-4 py-3 text-gray-500">{usuario.email}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="px-2 py-1 text-xs rounded bg-primary-100 text-primary-700 capitalize">
|
<span className={`px-2 py-1 text-xs rounded capitalize ${getRoleBadgeColor(usuario.role)}`}>
|
||||||
{usuario.role}
|
{usuario.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -79,7 +201,7 @@ export default function AdminUsuarios() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser(usuario)}
|
onClick={() => handleOpenEdit(usuario)}
|
||||||
className="text-primary-600 hover:text-primary-700 mr-3"
|
className="text-primary-600 hover:text-primary-700 mr-3"
|
||||||
>
|
>
|
||||||
Editar
|
Editar
|
||||||
@@ -93,30 +215,141 @@ export default function AdminUsuarios() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{usuarios.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No hay usuarios registrados
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal placeholder - implementar formulario completo */}
|
{/* Modal de formulario */}
|
||||||
{(showForm || editingUser) && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-xl max-w-lg w-full p-6">
|
<div className="bg-white rounded-xl max-w-lg w-full p-6">
|
||||||
<h2 className="text-xl font-bold mb-4">
|
<h2 className="text-xl font-bold mb-6">
|
||||||
{editingUser ? 'Editar Usuario' : 'Nuevo Usuario'}
|
{editingUser ? 'Editar Usuario' : 'Nuevo Usuario'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 mb-4">Formulario de usuario aquí...</p>
|
|
||||||
<div className="flex gap-3">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<button
|
{/* Nombre */}
|
||||||
onClick={() => {
|
<div>
|
||||||
setShowForm(false);
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
setEditingUser(null);
|
Nombre completo *
|
||||||
}}
|
</label>
|
||||||
className="btn btn-secondary flex-1"
|
<input
|
||||||
>
|
type="text"
|
||||||
Cancelar
|
name="nombre"
|
||||||
</button>
|
value={formData.nombre}
|
||||||
<button className="btn btn-primary flex-1">Guardar</button>
|
onChange={handleChange}
|
||||||
</div>
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="Juan Pérez"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="juan@empresa.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Contraseña {editingUser ? '(dejar vacío para mantener)' : '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rol */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rol *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
<option value="analista">Analista</option>
|
||||||
|
<option value="cliente">Cliente</option>
|
||||||
|
<option value="empleado">Empleado</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{formData.role === 'admin' && 'Acceso total al sistema'}
|
||||||
|
{formData.role === 'analista' && 'Gestiona clientes y genera reportes'}
|
||||||
|
{formData.role === 'cliente' && 'Ve solo su dashboard y reportes'}
|
||||||
|
{formData.role === 'empleado' && 'Permisos configurables por cliente'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cliente (solo para rol cliente o empleado) */}
|
||||||
|
{(formData.role === 'cliente' || formData.role === 'empleado') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Cliente asignado *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="cliente_id"
|
||||||
|
value={formData.cliente_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar cliente...</option>
|
||||||
|
{clientes.map((cliente) => (
|
||||||
|
<option key={cliente.id} value={cliente.id}>
|
||||||
|
{cliente.nombre_empresa}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{clientes.length === 0 && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
|
No hay clientes registrados. Cree un cliente primero.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botones */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="btn btn-secondary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user