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 { useState, useEffect } from 'react';
|
||||||
import { clientesApi, girosApi } from '../../services/api';
|
import { clientesApi, girosApi } from '../../services/api';
|
||||||
import { Giro } from '../../types';
|
import { Giro, Cliente } from '../../types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
cliente?: Cliente | null;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
export default function ClienteForm({ cliente, onSuccess, onCancel }: Props) {
|
||||||
const [giros, setGiros] = useState<Giro[]>([]);
|
const [giros, setGiros] = useState<Giro[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -17,13 +18,51 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
|||||||
moneda: 'MXN',
|
moneda: 'MXN',
|
||||||
});
|
});
|
||||||
const [logo, setLogo] = useState<File | null>(null);
|
const [logo, setLogo] = useState<File | null>(null);
|
||||||
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEditing = !!cliente;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
girosApi.list().then(setGiros).catch(() => toast.error('Error al cargar giros'));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -35,10 +74,19 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
|||||||
data.append('logo', logo);
|
data.append('logo', logo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEditing && cliente) {
|
||||||
|
data.append('_method', 'PUT');
|
||||||
|
await clientesApi.update(cliente.id, data);
|
||||||
|
toast.success('Cliente actualizado');
|
||||||
|
} else {
|
||||||
await clientesApi.create(data);
|
await clientesApi.create(data);
|
||||||
|
toast.success('Cliente creado');
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
toast.error('Error al crear cliente');
|
const message = error.response?.data?.message || `Error al ${isEditing ? 'actualizar' : 'crear'} cliente`;
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,24 +94,34 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="label">Nombre de la empresa</label>
|
<label className="label">Nombre de la empresa *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.nombre_empresa}
|
value={formData.nombre_empresa}
|
||||||
onChange={(e) => setFormData({ ...formData, nombre_empresa: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, nombre_empresa: e.target.value })}
|
||||||
className="input"
|
className="input"
|
||||||
required
|
placeholder="Empresa S.A. de C.V."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Giro</label>
|
<label className="label">Giro *</label>
|
||||||
<select
|
<select
|
||||||
value={formData.giro_id}
|
value={formData.giro_id}
|
||||||
onChange={(e) => setFormData({ ...formData, giro_id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, giro_id: e.target.value })}
|
||||||
className="input"
|
className="input"
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<option value="">Seleccionar giro...</option>
|
<option value="">Seleccionar giro...</option>
|
||||||
{giros.map((giro) => (
|
{giros.map((giro) => (
|
||||||
@@ -88,21 +146,33 @@ export default function ClienteForm({ onSuccess, onCancel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Logo (opcional)</label>
|
<label className="label">Logo {isEditing ? '(opcional, dejar vacío para mantener)' : '(opcional)'}</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => setLogo(e.target.files?.[0] || null)}
|
onChange={handleLogoChange}
|
||||||
className="input"
|
className="input"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Formatos: JPG, PNG, GIF. Máximo 2MB.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary flex-1">
|
<button
|
||||||
{loading ? 'Guardando...' : 'Crear Cliente'}
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
>
|
||||||
|
{loading ? 'Guardando...' : isEditing ? 'Actualizar' : 'Crear Cliente'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,19 +2,27 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { clientesApi, balanzasApi, reportesApi } from '../../services/api';
|
import { clientesApi, balanzasApi, reportesApi } from '../../services/api';
|
||||||
import { Cliente, Balanza, Reporte } from '../../types';
|
import { Cliente, Balanza, Reporte } from '../../types';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import UploadBalanza from '../../components/forms/UploadBalanza';
|
import UploadBalanza from '../../components/forms/UploadBalanza';
|
||||||
import GenerarReporte from '../../components/forms/GenerarReporte';
|
import GenerarReporte from '../../components/forms/GenerarReporte';
|
||||||
|
import ClienteForm from '../../components/forms/ClienteForm';
|
||||||
|
|
||||||
export default function ClienteDetail() {
|
export default function ClienteDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin, isAnalista } = useAuth();
|
||||||
const [cliente, setCliente] = useState<Cliente | null>(null);
|
const [cliente, setCliente] = useState<Cliente | null>(null);
|
||||||
const [balanzas, setBalanzas] = useState<Balanza[]>([]);
|
const [balanzas, setBalanzas] = useState<Balanza[]>([]);
|
||||||
const [reportes, setReportes] = useState<Reporte[]>([]);
|
const [reportes, setReportes] = useState<Reporte[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showGenerarReporte, setShowGenerarReporte] = 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(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -52,6 +60,27 @@ export default function ClienteDetail() {
|
|||||||
toast.success('Reporte generado exitosamente');
|
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) => {
|
const handleDownloadPdf = async (reporteId: number, nombre: string) => {
|
||||||
try {
|
try {
|
||||||
const blob = await reportesApi.downloadPdf(reporteId);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -81,7 +122,8 @@ export default function ClienteDetail() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/clientes')}
|
onClick={() => navigate('/clientes')}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
@@ -107,14 +149,35 @@ export default function ClienteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Balanzas */}
|
{/* Balanzas */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Balanzas de Comprobación</h2>
|
<h2 className="text-lg font-semibold">Balanzas de Comprobación</h2>
|
||||||
|
{canManage && (
|
||||||
<button onClick={() => setShowUpload(true)} className="btn btn-primary text-sm">
|
<button onClick={() => setShowUpload(true)} className="btn btn-primary text-sm">
|
||||||
+ Subir Balanza
|
+ Subir Balanza
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{balanzas.length === 0 ? (
|
{balanzas.length === 0 ? (
|
||||||
@@ -160,7 +223,7 @@ export default function ClienteDetail() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Reportes</h2>
|
<h2 className="text-lg font-semibold">Reportes</h2>
|
||||||
{balanzasCompletadas.length >= 1 && (
|
{canManage && balanzasCompletadas.length >= 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerarReporte(true)}
|
onClick={() => setShowGenerarReporte(true)}
|
||||||
className="btn btn-primary text-sm"
|
className="btn btn-primary text-sm"
|
||||||
@@ -194,7 +257,7 @@ export default function ClienteDetail() {
|
|||||||
onClick={() => navigate(`/dashboard/${cliente.id}/${reporte.id}`)}
|
onClick={() => navigate(`/dashboard/${cliente.id}/${reporte.id}`)}
|
||||||
className="text-primary-600 hover:text-primary-700 text-sm"
|
className="text-primary-600 hover:text-primary-700 text-sm"
|
||||||
>
|
>
|
||||||
Ver Dashboard
|
Ver
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownloadPdf(reporte.id, reporte.nombre)}
|
onClick={() => handleDownloadPdf(reporte.id, reporte.nombre)}
|
||||||
@@ -204,6 +267,14 @@ export default function ClienteDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteReporte(reporte.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
reporte.status === 'completado'
|
reporte.status === 'completado'
|
||||||
@@ -223,7 +294,7 @@ export default function ClienteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modales */}
|
{/* Modal: Subir Balanza */}
|
||||||
{showUpload && cliente && (
|
{showUpload && cliente && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-xl max-w-lg w-full">
|
<div className="bg-white rounded-xl max-w-lg w-full">
|
||||||
@@ -247,6 +318,7 @@ export default function ClienteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal: Generar Reporte */}
|
||||||
{showGenerarReporte && cliente && (
|
{showGenerarReporte && cliente && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-xl max-w-lg w-full">
|
<div className="bg-white rounded-xl max-w-lg w-full">
|
||||||
@@ -270,6 +342,59 @@ export default function ClienteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user