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