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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user