Add edit/delete functionality for clients

- ClienteForm: Now supports both create and edit modes with logo preview
- ClienteDetail: Added edit and delete buttons with confirmation modal
- Added delete report functionality with confirmation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 23:47:46 -06:00
parent d443d8e9d5
commit d2addbc1f6
2 changed files with 235 additions and 40 deletions

View File

@@ -1,14 +1,15 @@
import { useState, useEffect } from 'react';
import { clientesApi, girosApi } from '../../services/api';
import { Giro } from '../../types';
import { Giro, Cliente } from '../../types';
import toast from 'react-hot-toast';
interface Props {
cliente?: Cliente | null;
onSuccess: () => void;
onCancel: () => void;
}
export default function ClienteForm({ onSuccess, onCancel }: Props) {
export default function ClienteForm({ cliente, onSuccess, onCancel }: Props) {
const [giros, setGiros] = useState<Giro[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
@@ -17,13 +18,51 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
moneda: 'MXN',
});
const [logo, setLogo] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const isEditing = !!cliente;
useEffect(() => {
girosApi.list().then(setGiros).catch(() => toast.error('Error al cargar giros'));
}, []);
useEffect(() => {
if (cliente) {
setFormData({
nombre_empresa: cliente.nombre_empresa,
giro_id: cliente.giro_id?.toString() || '',
moneda: cliente.moneda,
});
if (cliente.logo) {
setLogoPreview(`/storage/${cliente.logo}`);
}
}
}, [cliente]);
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
setLogo(file);
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.nombre_empresa.trim()) {
toast.error('El nombre de la empresa es requerido');
return;
}
if (!formData.giro_id) {
toast.error('Seleccione un giro');
return;
}
setLoading(true);
try {
@@ -35,10 +74,19 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
data.append('logo', logo);
}
await clientesApi.create(data);
if (isEditing && cliente) {
data.append('_method', 'PUT');
await clientesApi.update(cliente.id, data);
toast.success('Cliente actualizado');
} else {
await clientesApi.create(data);
toast.success('Cliente creado');
}
onSuccess();
} catch {
toast.error('Error al crear cliente');
} catch (error: any) {
const message = error.response?.data?.message || `Error al ${isEditing ? 'actualizar' : 'crear'} cliente`;
toast.error(message);
} finally {
setLoading(false);
}
@@ -46,24 +94,34 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Logo preview */}
{logoPreview && (
<div className="flex justify-center">
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-lg object-cover border"
/>
</div>
)}
<div>
<label className="label">Nombre de la empresa</label>
<label className="label">Nombre de la empresa *</label>
<input
type="text"
value={formData.nombre_empresa}
onChange={(e) => setFormData({ ...formData, nombre_empresa: e.target.value })}
className="input"
required
placeholder="Empresa S.A. de C.V."
/>
</div>
<div>
<label className="label">Giro</label>
<label className="label">Giro *</label>
<select
value={formData.giro_id}
onChange={(e) => setFormData({ ...formData, giro_id: e.target.value })}
className="input"
required
>
<option value="">Seleccionar giro...</option>
{giros.map((giro) => (
@@ -88,21 +146,33 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
</div>
<div>
<label className="label">Logo (opcional)</label>
<label className="label">Logo {isEditing ? '(opcional, dejar vacío para mantener)' : '(opcional)'}</label>
<input
type="file"
accept="image/*"
onChange={(e) => setLogo(e.target.files?.[0] || null)}
onChange={handleLogoChange}
className="input"
/>
<p className="text-xs text-gray-500 mt-1">
Formatos: JPG, PNG, GIF. Máximo 2MB.
</p>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={onCancel} className="btn btn-secondary flex-1">
<button
type="button"
onClick={onCancel}
className="btn btn-secondary flex-1"
disabled={loading}
>
Cancelar
</button>
<button type="submit" disabled={loading} className="btn btn-primary flex-1">
{loading ? 'Guardando...' : 'Crear Cliente'}
<button
type="submit"
disabled={loading}
className="btn btn-primary flex-1"
>
{loading ? 'Guardando...' : isEditing ? 'Actualizar' : 'Crear Cliente'}
</button>
</div>
</form>

View File

@@ -2,19 +2,27 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { clientesApi, balanzasApi, reportesApi } from '../../services/api';
import { Cliente, Balanza, Reporte } from '../../types';
import { useAuth } from '../../context/AuthContext';
import toast from 'react-hot-toast';
import UploadBalanza from '../../components/forms/UploadBalanza';
import GenerarReporte from '../../components/forms/GenerarReporte';
import ClienteForm from '../../components/forms/ClienteForm';
export default function ClienteDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, isAnalista } = useAuth();
const [cliente, setCliente] = useState<Cliente | null>(null);
const [balanzas, setBalanzas] = useState<Balanza[]>([]);
const [reportes, setReportes] = useState<Reporte[]>([]);
const [loading, setLoading] = useState(true);
const [showUpload, setShowUpload] = useState(false);
const [showGenerarReporte, setShowGenerarReporte] = useState(false);
const [showEditCliente, setShowEditCliente] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const canManage = isAdmin || isAnalista;
useEffect(() => {
if (id) {
@@ -52,6 +60,27 @@ export default function ClienteDetail() {
toast.success('Reporte generado exitosamente');
};
const handleClienteUpdated = () => {
setShowEditCliente(false);
if (id) loadData(parseInt(id));
};
const handleDeleteCliente = async () => {
if (!cliente) return;
setDeleting(true);
try {
await clientesApi.delete(cliente.id);
toast.success('Cliente eliminado');
navigate('/clientes');
} catch {
toast.error('Error al eliminar cliente');
} finally {
setDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleDownloadPdf = async (reporteId: number, nombre: string) => {
try {
const blob = await reportesApi.downloadPdf(reporteId);
@@ -66,6 +95,18 @@ export default function ClienteDetail() {
}
};
const handleDeleteReporte = async (reporteId: number) => {
if (!confirm('¿Estás seguro de eliminar este reporte?')) return;
try {
await reportesApi.delete(reporteId);
toast.success('Reporte eliminado');
if (id) loadData(parseInt(id));
} catch {
toast.error('Error al eliminar reporte');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -81,30 +122,50 @@ export default function ClienteDetail() {
return (
<div>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('/clientes')}
className="text-gray-400 hover:text-gray-600"
>
Volver
</button>
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
{cliente.logo ? (
<img
src={`/storage/${cliente.logo}`}
alt={cliente.nombre_empresa}
className="w-16 h-16 rounded-lg object-cover"
/>
) : (
<div className="w-16 h-16 rounded-lg bg-primary-100 flex items-center justify-center text-2xl">
🏢
<button
onClick={() => navigate('/clientes')}
className="text-gray-400 hover:text-gray-600"
>
Volver
</button>
<div className="flex items-center gap-4">
{cliente.logo ? (
<img
src={`/storage/${cliente.logo}`}
alt={cliente.nombre_empresa}
className="w-16 h-16 rounded-lg object-cover"
/>
) : (
<div className="w-16 h-16 rounded-lg bg-primary-100 flex items-center justify-center text-2xl">
🏢
</div>
)}
<div>
<h1 className="text-2xl font-bold text-gray-900">{cliente.nombre_empresa}</h1>
<p className="text-gray-500">{cliente.giro?.nombre} {cliente.moneda}</p>
</div>
)}
<div>
<h1 className="text-2xl font-bold text-gray-900">{cliente.nombre_empresa}</h1>
<p className="text-gray-500">{cliente.giro?.nombre} {cliente.moneda}</p>
</div>
</div>
{/* Acciones del cliente */}
{canManage && (
<div className="flex gap-2">
<button
onClick={() => setShowEditCliente(true)}
className="btn btn-secondary"
>
Editar
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="btn btn-danger"
>
Eliminar
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -112,9 +173,11 @@ export default function ClienteDetail() {
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Balanzas de Comprobación</h2>
<button onClick={() => setShowUpload(true)} className="btn btn-primary text-sm">
+ Subir Balanza
</button>
{canManage && (
<button onClick={() => setShowUpload(true)} className="btn btn-primary text-sm">
+ Subir Balanza
</button>
)}
</div>
{balanzas.length === 0 ? (
@@ -160,7 +223,7 @@ export default function ClienteDetail() {
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Reportes</h2>
{balanzasCompletadas.length >= 1 && (
{canManage && balanzasCompletadas.length >= 1 && (
<button
onClick={() => setShowGenerarReporte(true)}
className="btn btn-primary text-sm"
@@ -194,7 +257,7 @@ export default function ClienteDetail() {
onClick={() => navigate(`/dashboard/${cliente.id}/${reporte.id}`)}
className="text-primary-600 hover:text-primary-700 text-sm"
>
Ver Dashboard
Ver
</button>
<button
onClick={() => handleDownloadPdf(reporte.id, reporte.nombre)}
@@ -204,6 +267,14 @@ export default function ClienteDetail() {
</button>
</>
)}
{canManage && (
<button
onClick={() => handleDeleteReporte(reporte.id)}
className="text-red-600 hover:text-red-700 text-sm"
>
Eliminar
</button>
)}
<span
className={`px-2 py-1 rounded text-xs font-medium ${
reporte.status === 'completado'
@@ -223,7 +294,7 @@ export default function ClienteDetail() {
</div>
</div>
{/* Modales */}
{/* Modal: Subir Balanza */}
{showUpload && cliente && (
<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">
@@ -247,6 +318,7 @@ export default function ClienteDetail() {
</div>
)}
{/* Modal: Generar Reporte */}
{showGenerarReporte && cliente && (
<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">
@@ -270,6 +342,59 @@ export default function ClienteDetail() {
</div>
</div>
)}
{/* Modal: Editar Cliente */}
{showEditCliente && cliente && (
<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 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Editar Cliente</h2>
<button
onClick={() => setShowEditCliente(false)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<ClienteForm
cliente={cliente}
onSuccess={handleClienteUpdated}
onCancel={() => setShowEditCliente(false)}
/>
</div>
</div>
</div>
)}
{/* Modal: Confirmar Eliminar */}
{showDeleteConfirm && (
<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-md w-full p-6">
<h2 className="text-xl font-bold mb-4">Eliminar Cliente</h2>
<p className="text-gray-600 mb-6">
¿Estás seguro de eliminar a <strong>{cliente.nombre_empresa}</strong>?
Esta acción eliminará todas las balanzas y reportes asociados.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="btn btn-secondary flex-1"
disabled={deleting}
>
Cancelar
</button>
<button
onClick={handleDeleteCliente}
className="btn btn-danger flex-1"
disabled={deleting}
>
{deleting ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}