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:
2026-01-31 23:43:09 -06:00
parent 4c3dc94ff2
commit d443d8e9d5
3 changed files with 947 additions and 61 deletions

View File

@@ -1,34 +1,176 @@
import { useState, useEffect } from 'react';
import { adminApi } from '../../services/api';
import { ReglaMapeo } from '../../types';
import { ReglaMapeo, ReporteContable, CategoriaContable } from '../../types';
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() {
const [reglas, setReglas] = useState<ReglaMapeo[]>([]);
const [reportesContables, setReportesContables] = useState<ReporteContable[]>([]);
const [categoriasContables, setCategoriasContables] = useState<CategoriaContable[]>([]);
const [loading, setLoading] = useState(true);
const [filterSistema, setFilterSistema] = useState<string>('');
const sistemas = ['contpaqi', 'aspel', 'sap', 'odoo', 'alegra', 'generico'];
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadReglas();
loadData();
}, []);
const loadReglas = async () => {
const loadData = async () => {
try {
const data = await adminApi.reglasMapeeo.list();
setReglas(data);
const [reglasData, reportesData, categoriasData] = await Promise.all([
adminApi.reglasMapeeo.list(),
adminApi.catalogos.reportesContables(),
adminApi.catalogos.categoriasContables(),
]);
setReglas(reglasData);
setReportesContables(reportesData);
setCategoriasContables(categoriasData);
} catch {
toast.error('Error al cargar reglas');
toast.error('Error al cargar datos');
} finally {
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
? reglas.filter((r) => r.sistema_origen === filterSistema)
: reglas;
const categoriasFiltradas = formData.reporte_contable_id
? categoriasContables.filter((c) => c.reporte_contable_id === formData.reporte_contable_id)
: [];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -41,7 +183,9 @@ export default function AdminReglasMapeeo() {
<div>
<div className="flex items-center justify-between mb-6">
<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>
{/* Filtro */}
@@ -49,10 +193,10 @@ export default function AdminReglasMapeeo() {
<select
value={filterSistema}
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>
{sistemas.map((sistema) => (
{SISTEMAS.map((sistema) => (
<option key={sistema} value={sistema}>
{sistema.charAt(0).toUpperCase() + sistema.slice(1)}
</option>
@@ -76,7 +220,7 @@ export default function AdminReglasMapeeo() {
</thead>
<tbody className="divide-y">
{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 font-mono text-xs">
{regla.cuenta_padre_codigo || '-'}
@@ -102,13 +246,28 @@ export default function AdminReglasMapeeo() {
</span>
</td>
<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
</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>
</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>
</table>
</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 con mayor prioridad se evalúan primero.</p>
</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>
);
}

View File

@@ -3,33 +3,158 @@ import { adminApi } from '../../services/api';
import { Umbral, Giro } from '../../types';
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() {
const [umbrales, setUmbrales] = useState<Umbral[]>([]);
const [giros, setGiros] = useState<Giro[]>([]);
const [loading, setLoading] = useState(true);
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(() => {
Promise.all([loadUmbrales(), loadGiros()]);
loadData();
}, []);
const loadUmbrales = async () => {
const loadData = async () => {
try {
const data = await adminApi.umbrales.list();
setUmbrales(data);
const [umbralesData, girosData] = await Promise.all([
adminApi.umbrales.list(),
adminApi.giros.list(),
]);
setUmbrales(umbralesData);
setGiros(girosData);
} catch {
toast.error('Error al cargar umbrales');
toast.error('Error al cargar datos');
} finally {
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 {
const data = await adminApi.giros.list();
setGiros(data);
const dataToSend = {
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 {
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;
const formatValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return '-';
return value.toString();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -49,7 +179,9 @@ export default function AdminUmbrales() {
<div>
<div className="flex items-center justify-between mb-6">
<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>
{/* Filtro */}
@@ -57,7 +189,7 @@ export default function AdminUmbrales() {
<select
value={filterGiro}
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="general">Umbrales generales</option>
@@ -85,21 +217,39 @@ export default function AdminUmbrales() {
</thead>
<tbody className="divide-y">
{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 text-gray-500">
{umbral.giro?.nombre || 'General'}
</td>
<td className="px-4 py-3 text-center">{umbral.muy_positivo ?? '-'}</td>
<td className="px-4 py-3 text-center">{umbral.positivo ?? '-'}</td>
<td className="px-4 py-3 text-center">{umbral.neutral ?? '-'}</td>
<td className="px-4 py-3 text-center">{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_positivo)}</td>
<td className="px-4 py-3 text-center">{formatValue(umbral.positivo)}</td>
<td className="px-4 py-3 text-center">{formatValue(umbral.neutral)}</td>
<td className="px-4 py-3 text-center">{formatValue(umbral.negativo)}</td>
<td className="px-4 py-3 text-center">{formatValue(umbral.muy_negativo)}</td>
<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>
</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>
</table>
</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 por giro tienen prioridad sobre los generales.</p>
</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>
);
}

View File

@@ -1,41 +1,163 @@
import { useState, useEffect } from 'react';
import { adminApi } from '../../services/api';
import { User } from '../../types';
import { adminApi, clientesApi } from '../../services/api';
import { User, Cliente } from '../../types';
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() {
const [usuarios, setUsuarios] = useState<User[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadUsuarios();
loadData();
}, []);
const loadUsuarios = async () => {
const loadData = async () => {
try {
const data = await adminApi.usuarios.list();
setUsuarios(data);
const [usuariosData, clientesData] = await Promise.all([
adminApi.usuarios.list(),
clientesApi.list(),
]);
setUsuarios(usuariosData);
setClientes(clientesData);
} catch {
toast.error('Error al cargar usuarios');
toast.error('Error al cargar datos');
} finally {
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) => {
if (!confirm('¿Estás seguro de eliminar este usuario?')) return;
try {
await adminApi.usuarios.delete(id);
toast.success('Usuario eliminado');
loadUsuarios();
loadData();
} catch {
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) {
return (
<div className="flex items-center justify-center h-64">
@@ -48,7 +170,7 @@ export default function AdminUsuarios() {
<div>
<div className="flex items-center justify-between mb-6">
<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
</button>
</div>
@@ -66,11 +188,11 @@ export default function AdminUsuarios() {
</thead>
<tbody className="divide-y">
{usuarios.map((usuario) => (
<tr key={usuario.id}>
<td className="px-4 py-3">{usuario.nombre}</td>
<tr key={usuario.id} className="hover:bg-gray-50">
<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">
<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}
</span>
</td>
@@ -79,7 +201,7 @@ export default function AdminUsuarios() {
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingUser(usuario)}
onClick={() => handleOpenEdit(usuario)}
className="text-primary-600 hover:text-primary-700 mr-3"
>
Editar
@@ -93,30 +215,141 @@ export default function AdminUsuarios() {
</td>
</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>
</table>
</div>
{/* Modal placeholder - implementar formulario completo */}
{(showForm || editingUser) && (
{/* 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-4">
<h2 className="text-xl font-bold mb-6">
{editingUser ? 'Editar Usuario' : 'Nuevo Usuario'}
</h2>
<p className="text-gray-500 mb-4">Formulario de usuario aquí...</p>
<div className="flex gap-3">
<button
onClick={() => {
setShowForm(false);
setEditingUser(null);
}}
className="btn btn-secondary flex-1"
>
Cancelar
</button>
<button className="btn btn-primary flex-1">Guardar</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre completo *
</label>
<input
type="text"
name="nombre"
value={formData.nombre}
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 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>
)}